Files
adminandClaude Opus 5.5 edef2b6323
feuerwerk / gate (push) Successful in 4m45s
feuerwerk / suite (push) Successful in 19m52s
Serve the editor from feuerwerk.nockal.com as well
The subdomain proxies to the same folder, but the build had the path
/feuerwerk/ baked in. On feuerwerk.nockal.com the page loaded its
assets, the API and the data URLs from /feuerwerk/... and got 404. The
session cookie had Path=/feuerwerk/, so the browser would never have
sent it to /api/ there either.

- site.ts takes the base path from the folder of the open page:
  /feuerwerk/ on nockal.com, / on the subdomain. Release links point at
  the address you are working on.
- vite.config.ts builds with a relative base. The copy plugin rewrites
  the pages to ./dist/assets/ and stops the build if it finds nothing
  to rewrite. The dev server now runs at http://localhost:5175/.
- Auth.php sets the cookie without a Path, so it belongs to the API
  folder the browser sees. Sign-in and sign-out also clear the old
  /feuerwerk/ cookie, otherwise it would keep signing in after a
  logout. Existing sessions stay valid.
- README: the new dev server address.

Tests:
- curl on both addresses: pages, JS, CSS and API 200
- sign-in, session, sign-out on both addresses
- e2e 70 against both addresses: 13/13 each, no CSP violation
- tsc, lint, prettier clean, site and releases tests 14/14
- PHP tests 7 of 8 files green, videos.test.php fails only because
  the web-php-fpm container has no ffmpeg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:40:18 +02:00

144 lines
5.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Who is asking. The browser sends the session cookie; the check tools log in the same way and
* send the same cookie, so there is exactly one way into the API.
*/
final class Auth
{
public function __construct(
private readonly UserStore $users,
private readonly SessionStore $sessions,
) {}
/** The signed-in user, or null when nobody is. */
public function user(): ?string
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
return $token === '' ? null : $this->sessions->user($token);
}
/** The signed-in user, or a 401 answer – every route but `auth/*` goes through here. */
public function require(): string
{
$user = $this->user();
if ($user === null) {
Response::error('Sign in first', 401);
}
return $user;
}
/**
* The signed-in user if they are an administrator (#180), else 401 or 403. Every route that
* may do more than a user goes through here: the editor only hides what it may not offer, the
* decision is made on this side.
*/
public function requireAdmin(): string
{
$user = $this->require();
if ($this->users->role($user) !== 'admin') {
Response::error('Administrators only', 403);
}
return $user;
}
/** `admin` or `user`; the session answer carries it so the editor knows what to offer. */
public function role(string $user): string
{
return $this->users->role($user);
}
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
public function logIn(string $username, string $password): ?string
{
$name = $this->users->verify($username, $password);
if ($name === null) {
return null;
}
$this->setCookie($this->sessions->create($name), time() + SessionStore::LIFETIME_SECONDS);
return $name;
}
public function logOut(): void
{
$token = $this->token();
if ($token !== '') {
$this->sessions->destroy($token);
}
$this->setCookie('', time() - 3600);
}
/**
* Sets a new password for `$user`, who has to type the old one again: a session alone is not
* enough, or somebody else's open tab would be all it takes to take an account over.
*
* Every other login of the account ends with it – a session that was started with the old
* password has no business outliving it. The one asking keeps theirs.
*
* @return bool False when the current password does not match; the caller answers 401 then.
*/
public function changePassword(string $user, string $current, string $new): bool
{
if ($this->users->verify($user, $current) === null) {
return false;
}
$this->users->put($user, $new);
$this->sessions->destroyAllFor($user, $this->token());
return true;
}
/**
* An administrator sets somebody's password without knowing the old one (#179). That account's
* sessions end with it, except the one asking when it is their own.
*/
public function resetPassword(string $user, string $new): void
{
$this->users->put($user, $new);
$this->sessions->destroyAllFor($user, $this->token());
}
/** Signs an account out everywhere – it was renamed or deleted, and its sessions carry the old name. */
public function endSessionsOf(string $user): void
{
$this->sessions->destroyAllFor($user);
}
private function token(): string
{
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
}
/**
* The cookie has no `Path`: the browser takes the folder of the API as it sees it, which is
* `/feuerwerk/api` on nockal.com and `/api` on feuerwerk.nockal.com. nginx sees the second as
* the first, so no path this side writes down would be right for both. The editor's pages and
* the data never needed the cookie.
*
* Before that, the cookie was set for the whole site (`/feuerwerk/`). Such an old one is ended
* with every new one, or it would still sign a browser in after signing out.
*/
private function setCookie(string $value, int $expires): void
{
$options = [
'expires' => $expires,
'httponly' => true,
'samesite' => 'Lax',
'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
];
setcookie(SessionStore::COOKIE, $value, $options);
setcookie(SessionStore::COOKIE, '', ['expires' => time() - 3600, 'path' => self::basePath()] + $options);
}
/**
* The path the old cookie was set for (`/feuerwerk/`), read off the script rather than
* written down: the same checkout answers under a different prefix in CI.
*/
private static function basePath(): string
{
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/feuerwerk/api/index.php');
$base = dirname(dirname($script));
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
}
}