aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2024-12-31 22:30:16 +0000
committerwinter2024-12-31 22:31:26 +0000
commit4cd7017da9a37e5b9f38c3f50dd1c904ea41cbcb (patch)
treed77f1fd223dae44f40b22c9810aeba238733310d
parent982e6213622bcb78a40d17f54cda27225a1d84b3 (diff)
implement user accounts and login
-rw-r--r--Digitigrade/HttpResponseStatus/TemporaryRedirect.php9
-rw-r--r--Digitigrade/Model/Session.php43
-rw-r--r--Digitigrade/Model/UserAccount.php52
-rw-r--r--locale/en_GB.json9
-rw-r--r--migrations/20241231_165056_create_user_account.php16
-rw-r--r--migrations/20241231_170929_create_session.php13
-rw-r--r--misc/defaults_password_hash.php5
-rw-r--r--misc/render_template.php3
-rw-r--r--routes/homepage.php3
-rw-r--r--routes/user_auth.php32
-rw-r--r--static/style.css92
-rw-r--r--templates/actor_avatar.php9
-rw-r--r--templates/login_form.php20
-rw-r--r--templates/login_page.php4
-rw-r--r--templates/skeleton.php13
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>