diff options
| author | winter | 2024-12-31 22:30:16 +0000 |
|---|---|---|
| committer | winter | 2024-12-31 22:31:26 +0000 |
| commit | 4cd7017da9a37e5b9f38c3f50dd1c904ea41cbcb (patch) | |
| tree | d77f1fd223dae44f40b22c9810aeba238733310d | |
| parent | 982e6213622bcb78a40d17f54cda27225a1d84b3 (diff) | |
implement user accounts and login
| -rw-r--r-- | Digitigrade/HttpResponseStatus/TemporaryRedirect.php | 9 | ||||
| -rw-r--r-- | Digitigrade/Model/Session.php | 43 | ||||
| -rw-r--r-- | Digitigrade/Model/UserAccount.php | 52 | ||||
| -rw-r--r-- | locale/en_GB.json | 9 | ||||
| -rw-r--r-- | migrations/20241231_165056_create_user_account.php | 16 | ||||
| -rw-r--r-- | migrations/20241231_170929_create_session.php | 13 | ||||
| -rw-r--r-- | misc/defaults_password_hash.php | 5 | ||||
| -rw-r--r-- | misc/render_template.php | 3 | ||||
| -rw-r--r-- | routes/homepage.php | 3 | ||||
| -rw-r--r-- | routes/user_auth.php | 32 | ||||
| -rw-r--r-- | static/style.css | 92 | ||||
| -rw-r--r-- | templates/actor_avatar.php | 9 | ||||
| -rw-r--r-- | templates/login_form.php | 20 | ||||
| -rw-r--r-- | templates/login_page.php | 4 | ||||
| -rw-r--r-- | templates/skeleton.php | 13 |
15 files changed, 303 insertions, 20 deletions
diff --git a/Digitigrade/HttpResponseStatus/TemporaryRedirect.php b/Digitigrade/HttpResponseStatus/TemporaryRedirect.php index fbf0def..4c9a794 100644 --- a/Digitigrade/HttpResponseStatus/TemporaryRedirect.php +++ b/Digitigrade/HttpResponseStatus/TemporaryRedirect.php @@ -12,6 +12,9 @@ class TemporaryRedirect extends \Exception implements HttpResponseStatus { } public function httpCode(): int { + if (isset($_SERVER['HTTP_HX_REQUEST'])) { + return 200; + } return 302; } @@ -20,6 +23,10 @@ class TemporaryRedirect extends \Exception implements HttpResponseStatus { } public function httpHeaders() { - header("Location: $this->target"); + if (isset($_SERVER['HTTP_HX_REQUEST'])) { + header("HX-Location: $this->target"); + } else { + header("Location: $this->target"); + } } }
\ No newline at end of file diff --git a/Digitigrade/Model/Session.php b/Digitigrade/Model/Session.php new file mode 100644 index 0000000..31fb8bb --- /dev/null +++ b/Digitigrade/Model/Session.php @@ -0,0 +1,43 @@ +<?php +namespace Digitigrade\Model; + +use Digitigrade\GlobalConfig; +use Digitigrade\Model; + +class Session extends Model { + public string $token; + public UserAccount $userAccount; + + public static function create(UserAccount $user): self { + $session = new self(); + $session->userAccount = $user; + $session->token = random_printable_bytes(); + $session->save(); + return $session; + } + + protected function getUpdateWhereClause(\PDO $db): ?string { + if (self::findByToken($this->token) != null) { + return 'token = ' . $db->quote($this->token); + } + return null; + } + + public static function findByToken(#[\SensitiveParameter] string $token): ?self { + return self::findWhere('token = ?', [$token]); + } + + public static function findAllByAccount(UserAccount $userAccount): array { + return self::findAllWhere('user_account = ?', [$userAccount->id]); + } + + public function setCookie(bool $rememberSession) { + setcookie('digitigrade-session', $this->token, [ + 'expires' => $rememberSession ? (new \DateTimeImmutable('1 year'))->getTimestamp() : null, + 'httponly' => true, + 'samesite' => 'Lax', + 'path' => '/', + 'secure' => GlobalConfig::getInstance()->isHttps() + ]); + } +}
\ No newline at end of file diff --git a/Digitigrade/Model/UserAccount.php b/Digitigrade/Model/UserAccount.php new file mode 100644 index 0000000..3d0e2a4 --- /dev/null +++ b/Digitigrade/Model/UserAccount.php @@ -0,0 +1,52 @@ +<?php +namespace Digitigrade\Model; + +use Digitigrade\Model; + +class UserAccount extends Model { + public ?int $id; + public string $email; + public string $passwordHash; + public ?Actor $actor; + public bool $banned = false; + + public static function create(string $email, #[\SensitiveParameter] string $password): self { + $user = new self(); + $user->email = $email; + $user->passwordHash = defaults_password_hash($password); + $user->actor = null; + $user->save(); + return $user; + } + + public function createLinkedActor(string $handle) { + $this->actor = Actor::create($handle); + $this->save(); + } + + protected function getUpdateWhereClause(\PDO $db): ?string { + if (isset($this->id) && self::find($this->{'id'}) != null) { + return 'id = ' . $this->{'id'}; + } + if (self::findByEmail($this->email) != null) { + return 'email = ' . $db->quote($this->email); + } + return null; + } + + public static function findByEmail(string $email): ?self { + return self::findWhere('email = ?', [$email]); + } + + public static function findByCurrentSession(): ?self { + if (!isset($_COOKIE['digitigrade-session'])) { + return null; + } + $token = $_COOKIE['digitigrade-session']; + return Session::findByToken($token)?->userAccount; + } + + public function verifyPassword(#[\SensitiveParameter] $attemptedPassword): bool { + return password_verify($attemptedPassword, $this->passwordHash); + } +}
\ No newline at end of file diff --git a/locale/en_GB.json b/locale/en_GB.json index b1ed2b2..917e375 100644 --- a/locale/en_GB.json +++ b/locale/en_GB.json @@ -14,5 +14,12 @@ "timeline.global.shortName": "Global", "timeline.local.shortName": "Local", "digitigrade": "Digitigrade", - "note.resharedBy": "Reshared by %s" + "note.resharedBy": "Reshared by %s", + "navigation.login": "Log in", + "login.pageTitle": "Log in", + "login.email": "Email address", + "login.password": "Password", + "login.rememberMe": "Remember me (stay logged in)", + "login.action": "Log in", + "login.failed": "Invalid email address or password" } diff --git a/migrations/20241231_165056_create_user_account.php b/migrations/20241231_165056_create_user_account.php new file mode 100644 index 0000000..3a30547 --- /dev/null +++ b/migrations/20241231_165056_create_user_account.php @@ -0,0 +1,16 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20241231_165056, function (PDO $db) { + // local user accounts + $db->exec(<<<END + CREATE TABLE user_account ( + id bigserial primary key, + email text not null unique, + password_hash text not null, + actor bigint unique references actor on delete restrict, + banned boolean not null default false + ); + END); +}); diff --git a/migrations/20241231_170929_create_session.php b/migrations/20241231_170929_create_session.php new file mode 100644 index 0000000..e24a78c --- /dev/null +++ b/migrations/20241231_170929_create_session.php @@ -0,0 +1,13 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20241231_170929, function (PDO $db) { + // login sessions + $db->exec(<<<END + CREATE TABLE session ( + token text primary key, + user_account bigint not null references user_account + ); + END); +}); diff --git a/misc/defaults_password_hash.php b/misc/defaults_password_hash.php new file mode 100644 index 0000000..427179b --- /dev/null +++ b/misc/defaults_password_hash.php @@ -0,0 +1,5 @@ +<?php + +function defaults_password_hash(#[\SensitiveParameter] string $password) { + return password_hash($password, PASSWORD_ARGON2ID); +}
\ No newline at end of file diff --git a/misc/render_template.php b/misc/render_template.php index 1ee349d..294fc16 100644 --- a/misc/render_template.php +++ b/misc/render_template.php @@ -35,8 +35,11 @@ function render_template(string $templateName, array $args = []) { } function call_template(string $templateName, array $args = [], callable $slotContent = null) { global $__spooky_magical_template_global_state; + // note to self: yes this does actually clone the array + $__spooky_magical_template_global_state['previousState'] = $__spooky_magical_template_global_state; $__spooky_magical_template_global_state['slotContent'] = $slotContent; render_template($templateName, $args); + $__spooky_magical_template_global_state = $__spooky_magical_template_global_state['previousState']; } } diff --git a/routes/homepage.php b/routes/homepage.php index c147d67..db8ea72 100644 --- a/routes/homepage.php +++ b/routes/homepage.php @@ -5,7 +5,8 @@ use Digitigrade\HttpResponseStatus\TemporaryRedirect; use Digitigrade\Router; Router::getInstance()->mount('/', function (array $args) { - if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'text/html')) { + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + if (str_contains($accept, 'text/html') || str_contains($accept, '*/*')) { throw new TemporaryRedirect('/feed/global'); } throw new NotFound(); diff --git a/routes/user_auth.php b/routes/user_auth.php new file mode 100644 index 0000000..afdf8d8 --- /dev/null +++ b/routes/user_auth.php @@ -0,0 +1,32 @@ +<?php + +use Digitigrade\HttpResponseStatus\TemporaryRedirect; +use Digitigrade\Model\Session; +use Digitigrade\Model\UserAccount; +use Digitigrade\Router; + +Router::getInstance()->mount('/login', function (array $args) { + if (UserAccount::findByCurrentSession() != null) { + // already logged in + throw new TemporaryRedirect('/'); + } + $failedVerification = false; + if (isset($_POST['email'], $_POST['password'])) { + $failedVerification = true; + // try to log in? + $user = UserAccount::findByEmail($_POST['email']); + if ($user?->verifyPassword($_POST['password'])) { + // success + $session = Session::create($user); + $session->setCookie(isset($_POST['rememberSession'])); + throw new TemporaryRedirect('/'); + } + } + + if (isset($_SERVER['HTTP_HX_CURRENT_URL']) && str_ends_with($_SERVER['HTTP_HX_CURRENT_URL'], '/login')) { + // htmx form submission, therefore just return the form itself + render_template('login_form', ['email' => $_POST['email'] ?? '', 'failed' => $failedVerification]); + } else { + render_template('login_page', ['email' => $_POST['email'] ?? '', 'failed' => $failedVerification]); + } +});
\ No newline at end of file diff --git a/static/style.css b/static/style.css index 0809f63..07960ab 100644 --- a/static/style.css +++ b/static/style.css @@ -14,19 +14,13 @@ body { header { background: #3b005e; color: white; - padding: 16px; + padding: 8px; margin-bottom: 8px; display: flex; flex-direction: column; align-items: center; width: 100vw; box-sizing: border-box; - - nav { - width: min(800px, 100vw); - display: grid; - grid-template-columns: 1fr max-content 1fr; - } } #siteTitle { @@ -34,11 +28,28 @@ header { } nav { + width: min(800px, 100vw); + height: 32px; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr max-content 1fr; + align-items: center; + + > * { + margin: 0 8px; + } + + > :last-child { + display: grid; + grid-template-columns: 1fr max-content; + align-items: center; + justify-self: end; + gap: 8px; + } + ul { list-style: none; display: inline; - margin: 0; - margin-left: 8px; padding: 0; > li { display: inline; @@ -51,6 +62,10 @@ nav { } } } + img.avatar { + width: 32px; + aspect-ratio: 1; + } } main { @@ -111,8 +126,7 @@ article.note { grid-template-columns: max-content 1fr; gap: 16px; - picture.avatar, - picture.avatar > * { + img.avatar { width: 64px; aspect-ratio: 1; } @@ -205,6 +219,59 @@ a.icon { font-size: 24px; } +form { + border: 1px solid #3334; + background: #9892; + border-radius: 8px; + margin: 16px; + padding: 16px; + display: grid; + grid-auto-flow: row; + gap: 16px; + max-width: max-content; + + label:has(+ input) { + display: block; + font-size: 10pt; + color: #555; + } + input:not([type="checkbox"]) { + display: block; + border: 1px solid #3334; + border-radius: 8px; + margin: 8px 0; + padding: 8px; + font-family: inherit; + font-size: inherit; + color: inherit; + background: #fff; + width: 24em; + } + button { + background: #3b005e; + color: #fff; + font-family: inherit; + font-size: inherit; + border-radius: 8px; + border: none; + width: 100%; + padding: 8px; + box-sizing: border-box; + cursor: pointer; + &:disabled { + opacity: 80%; + cursor: default; + } + } + .error { + background: #f004; + border: 1px solid #f007; + border-radius: 8px; + padding: 16px; + margin: 0; + } +} + .fullProfile { border-radius: 8px; margin: 16px; @@ -217,8 +284,7 @@ a.icon { grid-template-columns: max-content 1fr max-content; gap: 16px; - picture.avatar, - picture.avatar > * { + img.avatar { width: 96px; aspect-ratio: 1; } diff --git a/templates/actor_avatar.php b/templates/actor_avatar.php index d428006..439072b 100644 --- a/templates/actor_avatar.php +++ b/templates/actor_avatar.php @@ -1,4 +1,5 @@ -<picture class="avatar"> - <source srcset="<?= $actor->avatar ?>"> - <img src="/static/default-avatar.svg"> -</picture>
\ No newline at end of file +<?php if (isset($actor->avatar)): ?> + <img class="avatar" src="<?= $actor->avatar ?>"> +<?php else: ?> + <img class="avatar" src="/static/default-avatar.svg"> +<?php endif; ?>
\ No newline at end of file diff --git a/templates/login_form.php b/templates/login_form.php new file mode 100644 index 0000000..2c32164 --- /dev/null +++ b/templates/login_form.php @@ -0,0 +1,20 @@ +<form method="post" hx-post="" hx-swap="outerHTML" hx-disabled-elt="find button"> + <div class="row"> + <label for="emailInput"><?= __('login.email') ?></label> + <input type="email" id="emailInput" name="email" value="<?= $email ?>" required> + </div> + <div class="row"> + <label for="passwordInput"><?= __('login.password') ?></label> + <input type="password" id="passwordInput" name="password" required> + </div> + <div class="row"> + <input type="checkbox" id="rememberMeInput" name="rememberSession"> + <label for="rememberMeInput"><?= __('login.rememberMe') ?></label> + </div> + <div class="row"> + <button><?= __('login.action') ?></button> + </div> + <?php if ($failed ?? false): ?> + <p class="error"><?= __('login.failed') ?></p> + <?php endif; ?> +</form>
\ No newline at end of file diff --git a/templates/login_page.php b/templates/login_page.php new file mode 100644 index 0000000..338330e --- /dev/null +++ b/templates/login_page.php @@ -0,0 +1,4 @@ +<?php call_template('skeleton', ['pageTitle' => __('login.pageTitle')], function () { + global $email, $failed; + call_template('login_form', ['email' => $email, 'failed' => $failed]); +});
\ No newline at end of file diff --git a/templates/skeleton.php b/templates/skeleton.php index 0d8afb4..94bc2f2 100644 --- a/templates/skeleton.php +++ b/templates/skeleton.php @@ -1,3 +1,6 @@ +<?php +$user = Digitigrade\Model\UserAccount::findByCurrentSession(); +?> <!DOCTYPE html> <html lang="en"> @@ -18,6 +21,16 @@ <li><a href="/feed/local"><?= __('timeline.local.shortName') ?></a></li> </ul> <span id="siteTitle"><?= __('digitigrade') ?></span> + <?php if ($user == null): ?> + <ul> + <li><a href="/login"><?= __('navigation.login') ?></a></li> + </ul> + <?php else: ?> + <div> + <?php call_template('actor_avatar', ['actor' => $user->actor]) ?> + <?= $user->actor->displayName ?> + </div> + <?php endif; ?> </nav> </header> <main> |
