diff --git a/README.md b/README.md index db50a03..f7371e5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ One very important variable is `DEV`. This indicates that the application operat If you install the application in the Docker stack for development (staging) environment, only the variables for external dependencies (API keys, map attribution, etc.) should be adapted. All other variables (for DB connection, static root, mailing, etc.) are fine with the default value. +### (Production only) Create cron job + +To maintain database (delete inactive users, old sessions etc.), the command `db:maintain` should be regularly executed. It is recommened to create a cron job that runs every hour: + +``` +0 * * * * /path/to/your/installation/mapg db:maintain >>/var/log/cron-mapguesser.log 2>&1 +``` + ### Finalize installation After you set the environment variables in the `.env` file, execute the following command: diff --git a/mail/password-reset.html b/mail/password-reset.html index bf5b60d..9248002 100644 --- a/mail/password-reset.html +++ b/mail/password-reset.html @@ -2,8 +2,9 @@ Hi,

You recently requested password reset on {{APP_NAME}} with this email address ({{EMAIL}}). To reset the password to your account, please click on the following link:
-{{RESET_LINK}}
-(This link expires at {{EXPIRES}}.) +{{RESET_LINK}} +

+You can reset your password with this link util {{EXPIRES}}.

If you did not requested password reset, no further action is required, your account is not touched.

diff --git a/mail/signup.html b/mail/signup.html index dcb3849..ebdec4f 100644 --- a/mail/signup.html +++ b/mail/signup.html @@ -1,10 +1,14 @@ Hi,

-You recently signed up on {{APP_NAME}} with this email address ({{EMAIL}}). To activate your account, please click on the following link:
+You recently signed up on {{APP_NAME}} with this email address ({{EMAIL}}). +To activate your account, please click on the following link:
{{ACTIVATE_LINK}}

-If you did not sign up on {{APP_NAME}} or changed your mind, no further action is required, your email address will be deleted soon.
-However if you want to immediately delete it, please click on the following link:
+You can activate your account until {{ACTIVATABLE_UNTIL}}. +If you don't activate your account, your email address will be permanently deleted after this point of time. +

+If you did not sign up on {{APP_NAME}} or changed your mind, no further action is required. +However if you want to immediately delete your email address, please click on the following link:
{{CANCEL_LINK}}

Have fun on {{APP_NAME}}! diff --git a/mapg b/mapg index 8acfda2..65ffefd 100755 --- a/mapg +++ b/mapg @@ -5,8 +5,9 @@ require 'main.php'; $app = new Symfony\Component\Console\Application('MapGuesser Console', ''); -$app->add(new MapGuesser\Cli\DatabaseMigration()); +$app->add(new MapGuesser\Cli\MigrateDatabaseCommand()); $app->add(new MapGuesser\Cli\AddUserCommand()); $app->add(new MapGuesser\Cli\LinkViewCommand()); +$app->add(new \MapGuesser\Cli\MaintainDatabaseCommand()); $app->run(); diff --git a/scripts/install.sh b/scripts/install.sh index d6191c5..2ba224f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -16,7 +16,7 @@ echo "Installing MapGuesser DB..." mysql --host=${DB_HOST} --user=${DB_USER} --password=${DB_PASSWORD} ${DB_NAME} < ${ROOT_DIR}/db/mapguesser.sql echo "Migrating DB..." -(cd ${ROOT_DIR} && ./mapg migrate) +(cd ${ROOT_DIR} && ./mapg db:migrate) if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then echo "Minifying JS, CSS and SVG files..." diff --git a/scripts/update.sh b/scripts/update.sh index 8c44bc4..2bd04b6 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -15,7 +15,7 @@ echo "Installing Yarn packages..." (cd ${ROOT_DIR}/public/static && yarn install) echo "Migrating DB..." -(cd ${ROOT_DIR} && ./mapg migrate) +(cd ${ROOT_DIR} && ./mapg db:migrate) if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then echo "Minifying JS, CSS and SVG files..." diff --git a/src/Cli/MaintainDatabaseCommand.php b/src/Cli/MaintainDatabaseCommand.php new file mode 100644 index 0000000..f2afc70 --- /dev/null +++ b/src/Cli/MaintainDatabaseCommand.php @@ -0,0 +1,107 @@ +persistentDataManager = new PersistentDataManager(); + $this->userRepository = new UserRepository(); + $this->userConfirmationRepository = new UserConfirmationRepository(); + $this->userPasswordResetterRepository = new UserPasswordResetterRepository(); + } + + public function configure() + { + $this->setName('db:maintain') + ->setDescription('Maintain database.'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + try { + $this->deleteInactiveExpiredUsers(); + $this->deleteExpiredPasswordResetters(); + $this->deleteExpiredSessions(); + } catch (\Exception $e) { + $output->writeln('Maintenance failed!'); + $output->writeln(''); + + $output->writeln((string) $e); + $output->writeln(''); + + return 1; + } + + $output->writeln('Maintenance was successful!'); + $output->writeln(''); + + return 0; + } + + private function deleteInactiveExpiredUsers(): void + { + \Container::$dbConnection->startTransaction(); + + foreach ($this->userRepository->getAllInactiveExpired() as $user) { + //TODO: these can be in some wrapper class + $userConfirmation = $this->userConfirmationRepository->getByUser($user); + if ($userConfirmation !== null) { + $this->pdm->deleteFromDb($userConfirmation); + } + + $userPasswordResetter = $this->userPasswordResetterRepository->getByUser($user); + if ($userPasswordResetter !== null) { + $this->pdm->deleteFromDb($userPasswordResetter); + } + + $this->persistentDataManager->deleteFromDb($user); + } + + \Container::$dbConnection->commit(); + } + + private function deleteExpiredPasswordResetters(): void + { + foreach ($this->userPasswordResetterRepository->getAllExpired() as $passwordResetter) { + $this->persistentDataManager->deleteFromDb($passwordResetter); + } + } + + private function deleteExpiredSessions(): void + { + //TODO: model may be used for sessions too + $select = new Select(\Container::$dbConnection, 'sessions'); + $select->columns(['id']); + $select->where('updated', '<', (new DateTime('-7 days'))->format('Y-m-d H:i:s')); + + $result = $select->execute(); + + while ($session = $result->fetch(IResultSet::FETCH_ASSOC)) { + $modify = new Modify(\Container::$dbConnection, 'sessions'); + $modify->setId($session['id']); + $modify->delete(); + } + } +} diff --git a/src/Cli/DatabaseMigration.php b/src/Cli/MigrateDatabaseCommand.php similarity index 97% rename from src/Cli/DatabaseMigration.php rename to src/Cli/MigrateDatabaseCommand.php index f7f600b..ac81743 100644 --- a/src/Cli/DatabaseMigration.php +++ b/src/Cli/MigrateDatabaseCommand.php @@ -7,11 +7,11 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class DatabaseMigration extends Command +class MigrateDatabaseCommand extends Command { public function configure() { - $this->setName('migrate') + $this->setName('db:migrate') ->setDescription('Migration of database changes.'); } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 92d7a46..e54d44a 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -328,7 +328,7 @@ class LoginController \Container::$dbConnection->commit(); - $this->sendConfirmationEmail($user->getEmail(), $token); + $this->sendConfirmationEmail($user->getEmail(), $token, $user->getCreatedDate()); $this->request->session()->delete('tmp_user_data'); @@ -551,7 +551,7 @@ class LoginController return new JsonContent(['success' => true]); } - private function sendConfirmationEmail(string $email, string $token): void + private function sendConfirmationEmail(string $email, string $token, DateTime $created): void { $mail = new Mail(); $mail->addRecipient($email); @@ -562,6 +562,7 @@ class LoginController \Container::$routeCollection->getRoute('signup.activate')->generateLink(['token' => $token]), 'CANCEL_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('signup.cancel')->generateLink(['token' => $token]), + 'ACTIVATABLE_UNTIL' => (clone $created)->add(new DateInterval('P1D'))->format('Y-m-d H:i T') ]); $mail->send(); } @@ -578,7 +579,7 @@ class LoginController $this->pdm->saveToDb($confirmation); - $this->sendConfirmationEmail($user->getEmail(), $confirmation->getToken()); + $this->sendConfirmationEmail($user->getEmail(), $confirmation->getToken(), $user->getCreatedDate()); return true; } @@ -603,7 +604,7 @@ class LoginController 'EMAIL' => $email, 'RESET_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token]), - 'EXPIRES' => $expires->format('Y-m-d H:i:s T') + 'EXPIRES' => $expires->format('Y-m-d H:i T') ]); $mail->send(); } diff --git a/src/PersistentData/Model/User.php b/src/PersistentData/Model/User.php index 147362d..a4ebf60 100644 --- a/src/PersistentData/Model/User.php +++ b/src/PersistentData/Model/User.php @@ -90,7 +90,7 @@ class User extends Model implements IUser return $this->googleSub; } - public function getCreatedData(): DateTime + public function getCreatedDate(): DateTime { return $this->created; } diff --git a/src/Repository/UserPasswordResetterRepository.php b/src/Repository/UserPasswordResetterRepository.php index 466c3c8..47c70d7 100644 --- a/src/Repository/UserPasswordResetterRepository.php +++ b/src/Repository/UserPasswordResetterRepository.php @@ -1,5 +1,7 @@ pdm->selectFromDb($select, UserPasswordResetter::class); } + + public function getAllExpired(): Generator + { + $select = new Select(\Container::$dbConnection); + $select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s')); + + yield from $this->pdm->selectMultipleFromDb($select, UserPasswordResetter::class); + } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index c7ddcaf..be96c79 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -1,5 +1,7 @@ pdm->selectFromDb($select, User::class); } + + public function getAllInactiveExpired(): Generator + { + $select = new Select(\Container::$dbConnection); + $select->where('active', '=', false); + $select->where('created', '<', (new DateTime('-1 day'))->format('Y-m-d H:i:s')); + + yield from $this->pdm->selectMultipleFromDb($select, User::class); + } } diff --git a/src/Session/DatabaseSessionHandler.php b/src/Session/DatabaseSessionHandler.php index 49f7f6f..3949625 100644 --- a/src/Session/DatabaseSessionHandler.php +++ b/src/Session/DatabaseSessionHandler.php @@ -71,17 +71,8 @@ class DatabaseSessionHandler implements ISessionHandler public function gc($maxlifetime): int { - $select = new Select(\Container::$dbConnection, 'sessions'); - $select->columns(['id']); - $select->where('updated', '<', (new DateTime('-' . $maxlifetime . ' seconds'))->format('Y-m-d H:i:s')); - - $result = $select->execute(); - - while ($session = $result->fetch(IResultSet::FETCH_ASSOC)) { - $modify = new Modify(\Container::$dbConnection, 'sessions'); - $modify->setId($session['id']); - $modify->delete(); - } + // empty on purpose + // old sessions are deleted by MaintainDatabaseCommand return true; } diff --git a/web.php b/web.php index 6ebfecf..920ad90 100644 --- a/web.php +++ b/web.php @@ -67,7 +67,8 @@ if (isset($_COOKIE['COOKIES_CONSENT'])) { session_set_save_handler(Container::$sessionHandler, true); session_start([ 'gc_maxlifetime' => 604800, - 'cookie_lifetime' => 604800, + 'gc_probability' => 0, // old sessions are deleted by MaintainDatabaseCommand + 'cookie_lifetime' => 604800, // TODO: cookie is not renewed so session can be lost 'cookie_httponly' => true, 'cookie_samesite' => 'Lax' ]);