Compare commits

...

3 Commits

Author SHA1 Message Date
khannurien
bfce3186f8 v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:24:38 +00:00
khannurien
2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00
khannurien
3a09d0e354 v3: a journal headline stays readable when you point at it — over an image card the title rests white on the scrim and hovers in the theme's accent, which over a busy or bright thumbnail could sink into the picture, and in nyt's light scheme went black on black. A hovered title now picks up a backing plate of its own, grown around the text by padding that an equal negative margin cancels, so nothing shifts, and hugging the headline rather than spanning the card. The plate is a dark glass by default and each theme reskins it through --journal-title-hover-* properties: smooth frosts it in page colour so terracotta on cream and jasmine on Prussian Blue stay the pairs, kanagawa sets it on sumiInk with a selection edge, nyt in square flat ink, brutalist as a solid accent stamp with a hard offset shadow, geocities as a neon marquee, melange as a coffee pillow in warm shadow. nyt's unread dot, inked in the theme's black accent, goes white over a photo too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:53:39 +00:00
61 changed files with 1474 additions and 560 deletions

View File

@@ -13,6 +13,7 @@ import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favic
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts"; import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts"; import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts"; import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -40,6 +41,7 @@ const MIGRATIONS: Migration[] = [
}, },
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical }, { name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
{ name: "0014_chat_read_state", up: up0014ChatReadState }, { name: "0014_chat_read_state", up: up0014ChatReadState },
{ name: "0015_token_revocation", up: up0015TokenRevocation },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,17 @@
import type { DatabaseSync } from "node:sqlite";
// Changing or resetting a password must end every session opened before it.
// Tokens are stateless JWTs, so each user carries a cut-off instead:
// `users.tokens_valid_after` (ISO), and a token issued before it is refused.
// NULL — the state every existing user starts in — means no cut-off.
//
// Idempotent: also runs against fresh databases created from schema.sql, where
// the column already exists.
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "tokens_valid_after")) {
db.exec(`ALTER TABLE users ADD COLUMN tokens_valid_after TEXT;`);
}
}

View File

@@ -31,7 +31,8 @@ CREATE TABLE users (
description TEXT, description TEXT,
invited_by TEXT REFERENCES users(id), invited_by TEXT REFERENCES users(id),
email TEXT NOT NULL, email TEXT NOT NULL,
chat_read_at TEXT chat_read_at TEXT,
tokens_valid_after TEXT
); );
CREATE TABLE votes ( CREATE TABLE votes (

View File

@@ -1,6 +1,67 @@
import type { Context } from "@oak/oak"; import type { Context } from "@oak/oak";
import { verifyJWT } from "./jwt.ts"; import { verifyJWT, verifyMediaToken } from "./jwt.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import {
APIErrorCode,
APIException,
type AuthPayload,
type Role,
} from "../model/interfaces.ts";
import { db } from "../model/db.ts";
/**
* Checks a signature-verified token against the live user row: the user must
* still exist, and the token must not predate their last password change
* (`users.tokens_valid_after`). Returns the live role, or null to refuse.
*
* `iat` has one-second resolution, so a token issued in the same second as the
* change survives it; one issued after it (the fresh one the change hands back)
* always does.
*/
function checkTokenOwner(
userId: string,
iat: number | undefined,
): { role: Role } | null {
const row = db.prepare(
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
).get(userId) as
| { role: Role; tokens_valid_after: string | null }
| undefined;
if (!row) return null;
if (row.tokens_valid_after) {
const cutoff = Math.floor(
new Date(row.tokens_valid_after).getTime() / 1000,
);
if ((iat ?? 0) < cutoff) return null;
}
return { role: row.role };
}
/**
* Verifies a session token end to end: signature, expiry, a user that still
* exists and no password change since it was issued. The role comes from the
* DB, not the token, since tokens last 7 days and roles change under them.
*/
export async function authenticate(
token: string,
): Promise<AuthPayload | null> {
const payload = await verifyJWT(token);
if (!payload) return null;
const live = checkTokenOwner(payload.userId, payload.iat);
if (!live) return null;
return { ...payload, role: live.role };
}
/**
* Resolves the `?token=` on a media URL to the viewer's user id. Takes a media
* token, or a session token from a client that predates them.
*/
export async function authenticateMedia(token: string): Promise<string | null> {
const media = await verifyMediaToken(token);
if (media) {
return checkTokenOwner(media.userId, media.iat) ? media.userId : null;
}
return (await authenticate(token))?.userId ?? null;
}
/** /**
* Extracts the userId from an optional Bearer token. * Extracts the userId from an optional Bearer token.
@@ -8,10 +69,10 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
* Distinguishes *missing* credentials from *invalid* ones: * Distinguishes *missing* credentials from *invalid* ones:
* - No `Authorization: Bearer …` header (or a placeholder value such as the * - No `Authorization: Bearer …` header (or a placeholder value such as the
* `Bearer undefined` that the client emits when logged out) → anonymous (null). * `Bearer undefined` that the client emits when logged out) → anonymous (null).
* - A real token that fails verification (expired / bad signature) → throws 401, * - A real token that fails verification (expired / bad signature / revoked) →
* so a stale client session surfaces as an auth error it can react to, instead * throws 401, so a stale client session surfaces as an auth error it can react
* of being silently downgraded to anonymous (which yields a misleading 404 on * to, instead of being silently downgraded to anonymous (which yields a
* private resources). * misleading 404 on private resources).
*/ */
export async function parseOptionalAuth(ctx: Context): Promise<string | null> { export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const authHeader = ctx.request.headers.get("Authorization"); const authHeader = ctx.request.headers.get("Authorization");
@@ -20,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const token = authHeader.substring(7).trim(); const token = authHeader.substring(7).trim();
if (!token || token === "undefined" || token === "null") return null; if (!token || token === "undefined" || token === "null") return null;
const payload = await verifyJWT(token); const payload = await authenticate(token);
if (!payload) { if (!payload) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
} }

View File

@@ -1,4 +1,5 @@
import { randomBytes, scrypt } from "node:crypto"; import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { Buffer } from "node:buffer";
import { jwtVerify, SignJWT } from "@panva/jose"; import { jwtVerify, SignJWT } from "@panva/jose";
import { import {
@@ -47,10 +48,40 @@ export async function createJWT(
): Promise<string> { ): Promise<string> {
return await new SignJWT(payload) return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
// `iat` is what a password change revokes against (users.tokens_valid_after).
.setIssuedAt()
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(JWT_KEY); .sign(JWT_KEY);
} }
// ── Media tokens ──────────────────────────────────────────────────────────────
// Media elements can't send an Authorization header, so a private dump's file
// and thumbnail URLs carry a token in the query string, where it lands in
// access logs and browser history. That token is this one: it only reads media,
// so a leaked URL exposes the owner's private files, not their account.
export async function createMediaToken(userId: string): Promise<string> {
return await new SignJWT({ purpose: "media", userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_KEY);
}
export async function verifyMediaToken(
token: string,
): Promise<{ userId: string; iat?: number } | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (payload.purpose !== "media" || typeof payload.userId !== "string") {
return null;
}
return { userId: payload.userId, iat: payload.iat };
} catch {
return null;
}
}
export async function verifyJWT(token: string): Promise<AuthPayload | null> { export async function verifyJWT(token: string): Promise<AuthPayload | null> {
try { try {
const { payload } = await jwtVerify(token, JWT_KEY); const { payload } = await jwtVerify(token, JWT_KEY);
@@ -86,7 +117,13 @@ export function verifyPassword(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
scrypt(password, salt, 64, (err, derivedKey) => { scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err); if (err) reject(err);
else resolve(hash === derivedKey.toString("hex")); else {
const expected = Buffer.from(hash ?? "", "hex");
resolve(
expected.length === derivedKey.length &&
timingSafeEqual(expected, derivedKey),
);
}
}); });
}); });
} }

137
api/lib/public-address.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Guards for server-side fetches of user-supplied URLs (link previews, the
* image proxy, Bandcamp resolution): the server must not become a way to reach
* addresses only it can see — loopback, the LAN, container networks, or the
* cloud metadata endpoint at 169.254.169.254.
*/
function parseIPv4(s: string): number[] | null {
const parts = s.split(".");
if (parts.length !== 4) return null;
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
}
function isPrivateIPv4([a, b]: number[]): boolean {
return a === 0 || // "this" network
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
(a === 169 && b === 254) || // link-local, cloud metadata
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 0) || // IETF protocol assignments (192.0.0.0/24 …)
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) || // benchmarking
a >= 224; // multicast, reserved, broadcast
}
/** 16 bytes, or null when `s` isn't an IPv6 address. */
function parseIPv6(s: string): number[] | null {
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
const lastColon = addr.lastIndexOf(":");
if (lastColon === -1) return null;
const tail = addr.slice(lastColon + 1);
if (tail.includes(".")) {
const v4 = parseIPv4(tail);
if (!v4) return null;
addr = `${addr.slice(0, lastColon + 1)}${
((v4[0] << 8) | v4[1]).toString(16)
}:${((v4[2] << 8) | v4[3]).toString(16)}`;
}
const halves = addr.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
const fill = 8 - head.length - rest.length;
if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest];
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.flatMap((g) => {
const n = parseInt(g, 16);
return [n >> 8, n & 0xff];
});
}
function isPrivateIPv6(b: number[]): boolean {
const zeroPrefix = (n: number) => b.slice(0, n).every((x) => x === 0);
// IPv4 carried inside IPv6 is judged as the IPv4 address it carries.
if (zeroPrefix(10) && b[10] === 0xff && b[11] === 0xff) { // ::ffff:a.b.c.d
return isPrivateIPv4(b.slice(12));
}
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
return true;
}
if ( // 64:ff9b::/96 NAT64
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
b.slice(4, 12).every((x) => x === 0)
) return isPrivateIPv4(b.slice(12));
if (b[0] === 0x20 && b[1] === 0x02) { // 2002::/16 6to4
return isPrivateIPv4(b.slice(2, 6));
}
return (b[0] & 0xfe) === 0xfc || // fc00::/7 unique local
(b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local
(b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) || // fec0::/10 site-local
b[0] === 0xff; // multicast
}
/** True for an IP literal outside the public internet. Non-IPs are false. */
export function isPrivateAddress(ip: string): boolean {
const v4 = parseIPv4(ip);
if (v4) return isPrivateIPv4(v4);
const v6 = parseIPv6(ip);
return v6 ? isPrivateIPv6(v6) : false;
}
function isIpLiteral(host: string): boolean {
return parseIPv4(host) !== null || parseIPv6(host) !== null;
}
/** `URL.hostname` keeps the brackets around an IPv6 literal. */
function bareHostname(url: URL): string {
return url.hostname.replace(/^\[(.*)\]$/, "$1");
}
/**
* The synchronous part of the check: a hostname that is a private IP literal,
* or a name that is local by definition. Names still need resolving (see
* resolvePublicAddress) — this only rejects what is obvious without DNS.
*/
export function isPrivateHostname(url: URL): boolean {
const host = bareHostname(url).toLowerCase();
if (host === "localhost" || host.endsWith(".localhost")) return true;
return isPrivateAddress(host);
}
/**
* Resolves `url`'s host and returns one of its addresses, or throws if the
* host is private or any address it resolves to is (a name with both a public
* and a private record is refused outright, since the fetch may pick either).
*
* The fetch itself resolves again, so a DNS server answering differently the
* second time (rebinding) isn't fully excluded; the curl fallback pins the
* address this returns.
*/
export async function resolvePublicAddress(url: URL): Promise<string> {
if (isPrivateHostname(url)) {
throw new Error(`Refusing to fetch private address ${url.hostname}`);
}
const host = bareHostname(url);
if (isIpLiteral(host)) return host;
const results = await Promise.allSettled([
Deno.resolveDns(host, "A"),
Deno.resolveDns(host, "AAAA"),
]);
const addresses = results.flatMap((r) =>
r.status === "fulfilled" ? r.value : []
);
if (addresses.length === 0) {
throw new Error(`Could not resolve ${host}`);
}
const blocked = addresses.find(isPrivateAddress);
if (blocked) {
throw new Error(`Refusing to fetch ${host}: resolves to ${blocked}`);
}
return addresses[0];
}

39
api/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,39 @@
// Token buckets keyed by the caller's choice (e.g. `${kind}:${userId}`) — per
// user rather than per socket or request, so more tabs or parallel requests
// don't buy more throughput.
interface Bucket {
tokens: number;
at: number;
}
const buckets = new Map<string, Bucket>();
export interface RateLimit {
/** Burst size. */
capacity: number;
/** Tokens regained per second. */
refill: number;
}
/** Takes one token for `key`; false when the bucket is empty. */
export function takeToken(key: string, limit: RateLimit): boolean {
const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
bucket.tokens = Math.min(
limit.capacity,
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
);
bucket.at = now;
buckets.set(key, bucket);
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
return true;
}
// Idle buckets have refilled by now for every limit in use; drop them so the
// map tracks only keys that have been active lately.
setInterval(() => {
const cutoff = Date.now() - 60 * 60_000;
for (const [key, bucket] of buckets) {
if (bucket.at < cutoff) buckets.delete(key);
}
}, 60_000);

23
api/lib/self-url.ts Normal file
View File

@@ -0,0 +1,23 @@
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
const SELF_HOSTS = new Set(
[PUBLIC_URL, FRONTEND_URL].flatMap((u) => {
try {
return [new URL(u).host.toLowerCase()];
} catch {
return [];
}
}),
);
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
/**
* A link to one of this site's own dump, user or playlist pages. Matched on the
* host as well as the path: `/users/bob` on some other site is someone else's
* page, not ours.
*/
export function isSelfUrl(url: URL): boolean {
return SELF_HOSTS.has(url.host.toLowerCase()) &&
SELF_PATH_RE.test(url.pathname);
}

View File

@@ -65,6 +65,7 @@ export async function serveUploadedFile(
filePath: string, filePath: string,
mime: string, mime: string,
cacheMaxAge = 3600, cacheMaxAge = 3600,
isPrivate = false,
): Promise<boolean> { ): Promise<boolean> {
let data: Uint8Array; let data: Uint8Array;
try { try {
@@ -75,7 +76,11 @@ export async function serveUploadedFile(
} }
ctx.response.headers.set("Content-Type", mime); ctx.response.headers.set("Content-Type", mime);
ctx.response.headers.set("Content-Disposition", "inline"); ctx.response.headers.set("Content-Disposition", "inline");
ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`); ctx.response.headers.set("X-Content-Type-Options", "nosniff");
ctx.response.headers.set(
"Cache-Control",
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
);
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
ctx.response.body = data; ctx.response.body = data;
return true; return true;

View File

@@ -4,13 +4,12 @@ import {
type RouterContext, type RouterContext,
type State, type State,
} from "@oak/oak"; } from "@oak/oak";
import { verifyJWT } from "../lib/jwt.ts"; import { authenticate } from "../lib/auth.ts";
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
type AuthPayload, type AuthPayload,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { getUserById } from "../services/user-service.ts";
import { can, type Permission } from "../lib/permissions.ts"; import { can, type Permission } from "../lib/permissions.ts";
export interface AuthState extends State { export interface AuthState extends State {
@@ -40,23 +39,15 @@ export async function authMiddleware<R extends string>(
} }
const token = authHeader.substring(7); const token = authHeader.substring(7);
const payload = await verifyJWT(token); // Also checks the user still exists and hasn't changed their password since
// the token was issued, and takes the live role from the DB.
const payload = await authenticate(token);
if (!payload) { if (!payload) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
} }
let user; ctx.state.user = payload;
try {
user = getUserById(payload.userId);
} catch {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found");
}
// Trust the live role from the DB, not the role baked into the JWT at login.
// Tokens last 7 days, so a role change (e.g. promotion to admin or a demotion)
// would otherwise not take effect until the user re-logs in.
ctx.state.user = { ...payload, role: user.role };
await next(); await next();
} }

View File

@@ -220,11 +220,14 @@ export interface AuthPayload {
username: string; username: string;
role: Role; role: Role;
exp: number; exp: number;
iat?: number;
} }
export function isAuthPayload(obj: unknown): obj is AuthPayload { export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj && return !!obj &&
typeof obj === "object" && typeof obj === "object" &&
// Purpose-scoped tokens (password reset, media) are never session tokens.
!("purpose" in obj) &&
"userId" in obj && typeof obj.userId === "string" && "userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" && "username" in obj && typeof obj.username === "string" &&
"role" in obj && isRole(obj.role) && "role" in obj && isRole(obj.role) &&

View File

@@ -37,6 +37,10 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/dumps" }); const router = new Router({ prefix: "/api/dumps" });
// Every route here takes a slug or an id; the services below them key on the
// id, so mutations always pass `dump.id` from the resolved dump, never the raw
// parameter.
// Categories arrive on the multipart create path as a JSON-encoded string field. // Categories arrive on the multipart create path as a JSON-encoded string field.
function parseCategoryIds(value: FormDataEntryValue | null): string[] { function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return []; if (typeof value !== "string" || !value) return [];
@@ -193,7 +197,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
} }
const updatedDump = await replaceFileDump( const updatedDump = await replaceFileDump(
dumpId, dump.id,
file, file,
typeof comment === "string" && comment ? comment : undefined, typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined, typeof title === "string" && title ? title : undefined,
@@ -228,7 +232,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
); );
} }
const updatedDump = await updateDump(dumpId, body); const updatedDump = await updateDump(dump.id, body);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump }; const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
@@ -306,7 +310,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
); );
} }
const updatedDump = await refreshDumpMetadata(dumpId); const updatedDump = await refreshDumpMetadata(dump.id);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump }; const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
@@ -324,7 +328,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
); );
} }
await deleteDump(dumpId); await deleteDump(dump.id);
const responseBody: APIResponse<null> = { success: true, data: null }; const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody; ctx.response.body = responseBody;

View File

@@ -2,7 +2,7 @@ import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { DUMPS_DIR } from "../lib/upload.ts"; import { DUMPS_DIR } from "../lib/upload.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { authenticateMedia } from "../lib/auth.ts";
const router = new Router({ prefix: "/api/files" }); const router = new Router({ prefix: "/api/files" });
@@ -14,15 +14,31 @@ const router = new Router({ prefix: "/api/files" });
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the * ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
* full UTF-8 name for modern browsers. * full UTF-8 name for modern browsers.
*/ */
function contentDisposition(name: string): string { function contentDisposition(
type: "inline" | "attachment",
name: string,
): string {
const asciiFallback = name const asciiFallback = name
.replace(/["\\]/g, "_") .replace(/["\\]/g, "_")
.replace(/[^\x20-\x7e]/g, "_"); .replace(/[^\x20-\x7e]/g, "_");
return `inline; filename="${asciiFallback}"; filename*=UTF-8''${ return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
encodeURIComponent(name) encodeURIComponent(name)
}`; }`;
} }
// The MIME type of an upload is whatever the uploader's browser claimed, and
// these responses come from the app's own origin — where the session token
// lives. Only types a browser renders without running the file's own code are
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
// or read the file still work.
const INLINE_MIME_RE =
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
function isInlineSafe(mime: string): boolean {
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
}
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params; const { dumpId } = ctx.params;
@@ -35,8 +51,8 @@ router.get("/:dumpId", async (ctx) => {
// arrives as a query param (same as the WebSocket route). Needed to authorize // arrives as a query param (same as the WebSocket route). Needed to authorize
// access to private dumps the requester owns. // access to private dumps the requester owns.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const payload = token ? await verifyJWT(token) : null; const viewerId = token ? await authenticateMedia(token) : null;
const dump = getDump(dumpId, payload?.userId); const dump = getDump(dumpId, viewerId ?? undefined);
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
throw new APIException( throw new APIException(
@@ -56,11 +72,27 @@ router.get("/:dumpId", async (ctx) => {
} }
const total = data.byteLength; const total = data.byteLength;
const inline = isInlineSafe(dump.fileMime);
ctx.response.headers.set("Content-Type", dump.fileMime); ctx.response.headers.set("Content-Type", dump.fileMime);
ctx.response.headers.set( ctx.response.headers.set(
"Content-Disposition", "Content-Disposition",
contentDisposition(dump.fileName), contentDisposition(inline ? "inline" : "attachment", dump.fileName),
); );
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
// Belt and braces for a file opened directly: no scripts, no plugins, no
// same-origin access. Chrome won't render a PDF in a sandboxed document, and
// its viewer never runs a PDF's scripts anyway, so PDFs go without.
if (dump.fileMime !== "application/pdf") {
ctx.response.headers.set(
"Content-Security-Policy",
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
);
}
// The URL may carry a media token; don't pass it on to wherever the file
// links.
ctx.response.headers.set("Referrer-Policy", "no-referrer");
// A private dump's bytes must not sit in a shared cache.
if (dump.isPrivate) ctx.response.headers.set("Cache-Control", "private");
ctx.response.headers.set("Accept-Ranges", "bytes"); ctx.response.headers.set("Accept-Ranges", "bytes");
const rangeHeader = ctx.request.headers.get("Range"); const rangeHeader = ctx.request.headers.get("Range");

View File

@@ -5,10 +5,24 @@ import {
tryFetchRichContent, tryFetchRichContent,
} from "../services/rich-content-service.ts"; } from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts"; import { APIErrorCode } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
const previewRouter = new Router(); const previewRouter = new Router();
previewRouter.get("/api/preview", async (ctx) => { const PROXYABLE_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon",
]);
// Signed-in only: it's the create form's preview, and an open one lets anyone
// use the server to fetch pages for them.
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
ctx.response.status = 400; ctx.response.status = 400;
@@ -42,8 +56,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
try { try {
const res = await fetchWithTimeout(url, 8000); const res = await fetchWithTimeout(url, 8000);
const contentType = res.headers.get("content-type") ?? ""; // Raster formats only. The bytes go out under this site's origin, so an
if (!contentType.startsWith("image/")) { // SVG (which can carry script) or anything else a browser might execute
// would run with the app's origin if opened directly.
const contentType = (res.headers.get("content-type") ?? "")
.split(";")[0].trim().toLowerCase();
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
await res.body?.cancel();
ctx.response.status = 400; ctx.response.status = 400;
return; return;
} }
@@ -51,6 +70,7 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const contentLength = Number(res.headers.get("content-length") ?? "0"); const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > MAX_SIZE) { if (contentLength > MAX_SIZE) {
await res.body?.cancel();
ctx.response.status = 400; ctx.response.status = 400;
return; return;
} }
@@ -62,6 +82,11 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
} }
ctx.response.headers.set("Content-Type", contentType); ctx.response.headers.set("Content-Type", contentType);
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
ctx.response.headers.set(
"Content-Security-Policy",
"sandbox; default-src 'none'",
);
ctx.response.headers.set("Cache-Control", "public, max-age=86400"); ctx.response.headers.set("Cache-Control", "public, max-age=86400");
ctx.response.body = bytes; ctx.response.body = bytes;
} catch { } catch {

View File

@@ -3,7 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts"; import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
import { serveUploadedFile } from "../lib/upload.ts"; import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { authenticateMedia } from "../lib/auth.ts";
const router = new Router({ prefix: "/api/thumbnails" }); const router = new Router({ prefix: "/api/thumbnails" });
@@ -28,6 +28,14 @@ async function generateThumbnail(
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
const cmd = new Deno.Command("ffmpeg", { const cmd = new Deno.Command("ffmpeg", {
args: [ args: [
// The upload's type is only what the uploader claimed, and ffmpeg probes
// the real format from the bytes — a "video" could be an HLS or concat
// playlist pointing ffmpeg at local files or internal URLs. Read nothing
// but the file itself, and only as an actual video container.
"-protocol_whitelist",
"file",
"-format_whitelist",
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc",
"-ss", "-ss",
"00:00:01", "00:00:01",
"-i", "-i",
@@ -58,8 +66,14 @@ router.get("/:dumpId", async (ctx) => {
// Token via query param (media elements can't send headers) — authorizes the // Token via query param (media elements can't send headers) — authorizes the
// owner's private dumps and gates the custom-thumbnail branch below too. // owner's private dumps and gates the custom-thumbnail branch below too.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const payload = token ? await verifyJWT(token) : null; const viewerId = token ? await authenticateMedia(token) : null;
const dump = getDump(dumpId, payload?.userId); const dump = getDump(dumpId, viewerId ?? undefined);
// The URL may carry a media token; keep it out of anyone else's logs.
ctx.response.headers.set("Referrer-Policy", "no-referrer");
// A private dump's thumbnail must not sit in a shared cache.
const cacheControl = dump.isPrivate
? "private, max-age=31536000, immutable"
: "public, max-age=31536000, immutable";
const customThumb = getDumpThumbnailInfo(dumpId); const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) { if (customThumb) {
@@ -67,6 +81,8 @@ router.get("/:dumpId", async (ctx) => {
ctx, ctx,
`${THUMBNAILS_DIR}/${customThumb.id}-custom`, `${THUMBNAILS_DIR}/${customThumb.id}-custom`,
customThumb.mime, customThumb.mime,
3600,
dump.isPrivate,
); );
if (served) return; if (served) return;
} }
@@ -85,10 +101,7 @@ router.get("/:dumpId", async (ctx) => {
try { try {
const data = await Deno.readFile(thumbPath); const data = await Deno.readFile(thumbPath);
ctx.response.headers.set("Content-Type", "image/jpeg"); ctx.response.headers.set("Content-Type", "image/jpeg");
ctx.response.headers.set( ctx.response.headers.set("Cache-Control", cacheControl);
"Cache-Control",
"public, max-age=31536000, immutable",
);
ctx.response.body = data; ctx.response.body = data;
return; return;
} catch { } catch {
@@ -116,10 +129,7 @@ router.get("/:dumpId", async (ctx) => {
const data = await Deno.readFile(thumbPath); const data = await Deno.readFile(thumbPath);
ctx.response.headers.set("Content-Type", "image/jpeg"); ctx.response.headers.set("Content-Type", "image/jpeg");
ctx.response.headers.set( ctx.response.headers.set("Cache-Control", cacheControl);
"Cache-Control",
"public, max-age=31536000, immutable",
);
ctx.response.body = data; ctx.response.body = data;
}); });

View File

@@ -7,10 +7,12 @@ import {
isRole, isRole,
isUpdateUserRequest, isUpdateUserRequest,
type PaginatedData, type PaginatedData,
type User,
validateRegisterUserRequest, validateRegisterUserRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { createJWT, verifyPassword } from "../lib/jwt.ts"; import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import { import {
type AuthContext, type AuthContext,
authMiddleware, authMiddleware,
@@ -26,7 +28,7 @@ import {
searchUsers, searchUsers,
updateUser, updateUser,
} from "../services/user-service.ts"; } from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { claimInvite, releaseInvite } from "../services/invite-service.ts";
import { import {
requestPasswordReset, requestPasswordReset,
resetPassword, resetPassword,
@@ -53,6 +55,22 @@ import {
// Users router // Users router
const router = new Router({ prefix: "/api/users" }); const router = new Router({ prefix: "/api/users" });
// A burst of 10 attempts, then one every 30s.
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
// The session token, plus the media-only token that goes in file and
// thumbnail URLs (see createMediaToken).
async function issueTokens(user: User) {
return {
token: await createJWT({
userId: user.id,
username: user.username,
role: user.role,
}),
mediaToken: await createMediaToken(user.id),
};
}
// Register a new user (requires a valid invite token) // Register a new user (requires a valid invite token)
router.post("/register", async (ctx) => { router.post("/register", async (ctx) => {
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
@@ -62,16 +80,16 @@ router.post("/register", async (ctx) => {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError); throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
} }
// Validate invite — throws 404/409 if bad // Claim the invite up front (throws 404/409 if bad) and hand it back if the
const inviterId = validateInvite(body.inviteToken); // account can't be created, so one invite can't open two accounts.
const inviterId = claimInvite(body.inviteToken);
const user = await createUser(body, inviterId); let user;
// Mark invite as used only after the user row is committed
try { try {
redeemInvite(body.inviteToken); user = await createUser(body, inviterId);
} catch (err) { } catch (err) {
console.error("[register] redeemInvite failed (user created):", err); releaseInvite(body.inviteToken);
throw err;
} }
// Send welcome email (fire-and-forget) // Send welcome email (fire-and-forget)
@@ -88,23 +106,16 @@ router.post("/register", async (ctx) => {
}).catch((err) => console.error("[register] welcome email failed:", err)); }).catch((err) => console.error("[register] welcome email failed:", err));
} }
const authToken = await createJWT({
userId: user.id,
username: user.username,
role: user.role,
});
const { passwordHash: _, ...publicUser } = user; const { passwordHash: _, ...publicUser } = user;
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: { token: authToken, user: publicUser }, data: { ...await issueTokens(user), user: publicUser },
}; };
}); });
// Login // Login
router.post("/login", async (ctx) => { router.post("/login", async (ctx) => {
try {
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
if (!isLoginUserRequest(body)) { if (!isLoginUserRequest(body)) {
@@ -115,9 +126,29 @@ router.post("/login", async (ctx) => {
); );
} }
const user = getUserByUsername(body.username); // Per account, so guessing one user's password is slow however many
const valid = await verifyPassword(body.password, user.passwordHash); // connections it comes from.
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
429,
"Too many login attempts — try again in a minute",
);
}
let user;
try {
user = getUserByUsername(body.username);
} catch {
// Same answer as a wrong password: don't confirm which usernames exist.
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Invalid username or password",
);
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) { if (!valid) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -126,25 +157,11 @@ router.post("/login", async (ctx) => {
); );
} }
const token = await createJWT({
userId: user.id,
username: user.username,
role: user.role,
});
const { passwordHash: _, ...publicUser } = user; const { passwordHash: _, ...publicUser } = user;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: { data: { ...await issueTokens(user), user: publicUser },
token,
user: publicUser,
},
}; };
} catch (err) {
console.error(err);
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
}
}); });
// Get current user profile // Get current user profile
@@ -177,6 +194,14 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
} }
}); });
// A media token for a session that predates them (see createMediaToken).
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = {
success: true,
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
};
});
// Request a password reset email (unauthenticated; always returns 200) // Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => { router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
@@ -241,8 +266,12 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
); );
} }
await updateUser(ctx.state.user.userId, { password: newPassword }); // Changing the password revokes every token issued before it, this
ctx.response.body = { success: true }; // session's included, so hand back fresh ones for the client to keep.
const updated = await updateUser(ctx.state.user.userId, {
password: newPassword,
});
ctx.response.body = { success: true, data: await issueTokens(updated) };
}); });
// Update current user profile (description, etc.) // Update current user profile (description, etc.)
@@ -255,10 +284,14 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
"Invalid request", "Invalid request",
); );
} }
// Self-service profile updates must never set the role. Role assignment is // Only the profile fields. The role is assigned by an admin, the password
// done out-of-band (DB/seed); stripping it here prevents self-promotion. // changes through /me/change-password (which asks for the current one), and
const { role: _role, ...safeBody } = body; // the username is fixed — a token alone must not be able to change any of
const updated = await updateUser(ctx.state.user.userId, safeBody); // them.
const updated = await updateUser(ctx.state.user.userId, {
description: body.description,
email: body.email,
});
const { passwordHash: _, email: _email, ...publicUser } = updated; const { passwordHash: _, email: _email, ...publicUser } = updated;
broadcastUserUpdated(publicUser); broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser }; ctx.response.body = { success: true, data: publicUser };

View File

@@ -1,6 +1,6 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts"; import { ALLOWED_ORIGINS } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { authenticate } from "../lib/auth.ts";
import { import {
broadcastChatMessage, broadcastChatMessage,
broadcastChatMessageDeleted, broadcastChatMessageDeleted,
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
import { import {
APIException, APIException,
type ClientToServerMessage, type ClientToServerMessage,
type User,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
const router = new Router(); const router = new Router();
@@ -64,19 +65,24 @@ router.get("/ws", async (ctx) => {
return; return;
} }
// authenticate() refuses a token whose user is gone or which predates a
// password change, and reads the live role from the DB. Such a socket is
// simply anonymous; the client's next API call gets the 401.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const authPayload = token ? await verifyJWT(token) : null; const authPayload = token ? await authenticate(token) : null;
let user: User | undefined;
try {
user = authPayload ? getUserById(authPayload.userId) : undefined;
} catch {
user = undefined;
}
const socket = ctx.upgrade(); const socket = ctx.upgrade();
// Read the live user so the role reflects the DB, not the (possibly stale)
// role embedded in the 7-day JWT.
const user = authPayload ? getUserById(authPayload.userId) : undefined;
const client: WsClient = { const client: WsClient = {
socket, socket,
userId: authPayload?.userId, userId: user?.id,
username: authPayload?.username, username: user?.username,
role: user?.role, role: user?.role,
avatarMime: user?.avatarMime, avatarMime: user?.avatarMime,
}; };
@@ -87,16 +93,10 @@ router.get("/ws", async (ctx) => {
broadcastPresence(); broadcastPresence();
try { try {
const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; const myVotes = user ? getUserVotes(user.id) : [];
const myCommentLikes = authPayload const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
? getUserCommentLikes(authPayload.userId) const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
: []; const chatRead = user ? getChatReadState(user.id) : undefined;
const unreadNotificationCount = authPayload
? getUnreadCount(authPayload.userId)
: 0;
const chatRead = authPayload
? getChatReadState(authPayload.userId)
: undefined;
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: "welcome", type: "welcome",
users: getOnlineUsers(), users: getOnlineUsers(),
@@ -112,16 +112,43 @@ router.get("/ws", async (ctx) => {
}); });
socket.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
let msg: ClientToServerMessage; // An exception escaping a WebSocket listener is uncaught in Deno and ends
// the process, so nothing a client sends may throw out of here.
try { try {
msg = JSON.parse(event.data as string); handleMessage(client, event.data);
} catch (err) {
console.error("[ws] message handler failed:", err);
}
});
socket.addEventListener("close", () => {
unregister(client);
broadcastPresence();
// If they were mid-compose, drop them from the typing set right away.
if (client.typingAt) broadcastTyping();
});
});
function handleMessage(client: WsClient, data: unknown): void {
if (typeof data !== "string") return;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch { } catch {
return; return;
} }
// Anything but an object with a string `type` (`null`, a number, an array)
// is ignored rather than read from.
if (
typeof parsed !== "object" || parsed === null ||
typeof (parsed as { type?: unknown }).type !== "string"
) return;
const msg = parsed as ClientToServerMessage;
const { socket } = client;
switch (msg.type) { switch (msg.type) {
case "ping": case "ping":
socket.send(JSON.stringify({ type: "pong" })); send(socket, { type: "pong" });
break; break;
case "pong": case "pong":
handleClientPong(client); handleClientPong(client);
@@ -163,80 +190,89 @@ router.get("/ws", async (ctx) => {
handleChatRead(client, msg.at); handleChatRead(client, msg.at);
break; break;
} }
}); }
socket.addEventListener("close", () => { // Sends only while the socket is open: `send` on a closing socket throws.
unregister(client); function send(socket: WebSocket, data: Record<string, unknown>): void {
broadcastPresence(); if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
// If they were mid-compose, drop them from the typing set right away. }
if (client.typingAt) broadcastTyping();
});
});
function handleVote( function handleVote(
client: WsClient, client: WsClient,
dumpId: string, dumpId: unknown,
action: "cast" | "remove", action: "cast" | "remove",
): void { ): void {
const { socket } = client; const { socket } = client;
if (!client.userId) { if (!client.userId) {
socket.send( send(socket, { type: "error", message: "Authentication required" });
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return; return;
} }
if (typeof dumpId !== "string") return;
try { try {
const newCount = action === "cast" const { voteCount, isPrivate } = action === "cast"
? castVote(dumpId, client.userId) ? castVote(dumpId, client.userId)
: removeVote(dumpId, client.userId); : removeVote(dumpId, client.userId);
socket.send(JSON.stringify({ send(socket, {
type: "vote_ack", type: "vote_ack",
dumpId, dumpId,
action, action,
success: true, success: true,
voteCount: newCount, voteCount,
})); });
broadcastVoteUpdate(dumpId, newCount, client.userId, action); // Only a private dump's owner can vote on it, so they're its whole
// audience.
broadcastVoteUpdate(
dumpId,
voteCount,
client.userId,
action,
isPrivate ? client.userId : undefined,
);
} catch (err) { } catch (err) {
const message = err instanceof APIException ? err.message : "Vote failed"; const message = err instanceof APIException ? err.message : "Vote failed";
socket.send(JSON.stringify({ type: "error", message, dumpId, action })); send(socket, { type: "error", message, dumpId, action });
} }
} }
function handleCommentLike( function handleCommentLike(
client: WsClient, client: WsClient,
commentId: string, commentId: unknown,
action: "cast" | "remove", action: "cast" | "remove",
): void { ): void {
const { socket } = client; const { socket } = client;
if (!client.userId) { if (!client.userId) {
socket.send( send(socket, { type: "error", message: "Authentication required" });
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return; return;
} }
if (typeof commentId !== "string") return;
try { try {
const newCount = action === "cast" const { likeCount, isPrivate } = action === "cast"
? castCommentLike(commentId, client.userId) ? castCommentLike(commentId, client.userId)
: removeCommentLike(commentId, client.userId); : removeCommentLike(commentId, client.userId);
socket.send(JSON.stringify({ send(socket, {
type: "comment_like_ack", type: "comment_like_ack",
commentId, commentId,
action, action,
likeCount: newCount, likeCount,
})); });
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action); broadcastCommentLikeUpdate(
commentId,
likeCount,
client.userId,
action,
isPrivate ? client.userId : undefined,
);
} catch (err) { } catch (err) {
const message = err instanceof APIException ? err.message : "Like failed"; const message = err instanceof APIException ? err.message : "Like failed";
socket.send(JSON.stringify({ type: "error", message, commentId, action })); send(socket, { type: "error", message, commentId, action });
} }
} }
@@ -253,10 +289,7 @@ function sendChatError(
message: string, message: string,
nonce?: string, nonce?: string,
): void { ): void {
if (client.socket.readyState !== WebSocket.OPEN) return; send(client.socket, { type: "error", scope: "chat", message, nonce });
client.socket.send(
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
);
} }
// Validates the common preconditions of a chat write: signed in and under the // Validates the common preconditions of a chat write: signed in and under the

View File

@@ -252,8 +252,11 @@ export function markChatRead(userId: string, at: string): ChatReadState {
const newest = db.prepare( const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`, `SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null }; ).get() as { newest: string | null };
// No messages yet: there's nothing to have read, and no newest message to
// cap a bogus far-future marker at — which could never be moved back.
if (!newest.newest) return getChatReadState(userId);
const iso = parsed.toISOString(); const iso = parsed.toISOString();
const target = newest.newest && iso > newest.newest ? newest.newest : iso; const target = iso > newest.newest ? newest.newest : iso;
db.prepare( db.prepare(
`UPDATE users SET chat_read_at = ? `UPDATE users SET chat_read_at = ?
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`, WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,

View File

@@ -2,7 +2,29 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts"; import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts";
export function castCommentLike(commentId: string, userId: string): number { export interface LikeResult {
likeCount: number;
/** Likes on a private dump's comments go to the dump's owner only. */
isPrivate: boolean;
}
// Visibility of the comment's dump, or a 404 when the user can't see it.
function commentVisibility(commentId: string, userId: string): boolean {
const row = db.prepare(
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
WHERE c.id = ?;`,
).get(commentId) as { is_private: number; user_id: string } | undefined;
if (!row || (row.is_private && row.user_id !== userId)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return Boolean(row.is_private);
}
export function castCommentLike(
commentId: string,
userId: string,
): LikeResult {
const isPrivate = commentVisibility(commentId, userId);
let likeCount: number; let likeCount: number;
try { try {
db.exec("BEGIN;"); db.exec("BEGIN;");
@@ -34,10 +56,20 @@ export function castCommentLike(commentId: string, userId: string): number {
try { try {
notifyCommentOwnerLike(userId, commentId); notifyCommentOwnerLike(userId, commentId);
} catch { /* ignore */ } } catch { /* ignore */ }
return likeCount; return { likeCount, isPrivate };
} }
export function removeCommentLike(commentId: string, userId: string): number { // No visibility check: taking back a like is harmless, and it's the user's own
// row.
export function removeCommentLike(
commentId: string,
userId: string,
): LikeResult {
const dumpRow = db.prepare(
`SELECT d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id
WHERE c.id = ?;`,
).get(commentId) as { is_private: number } | undefined;
const isPrivate = Boolean(dumpRow?.is_private);
try { try {
db.exec("BEGIN;"); db.exec("BEGIN;");
const result = db.prepare( const result = db.prepare(
@@ -54,7 +86,7 @@ export function removeCommentLike(commentId: string, userId: string): number {
`SELECT like_count FROM comments WHERE id = ?;`, `SELECT like_count FROM comments WHERE id = ?;`,
).get(commentId) as { like_count: number } | undefined; ).get(commentId) as { like_count: number } | undefined;
db.exec("COMMIT;"); db.exec("COMMIT;");
return row?.like_count ?? 0; return { likeCount: row?.like_count ?? 0, isPrivate };
} catch (err) { } catch (err) {
if (err instanceof APIException) throw err; if (err instanceof APIException) throw err;
db.exec("ROLLBACK;"); db.exec("ROLLBACK;");

View File

@@ -36,6 +36,7 @@ import {
DUMP_MAX_FILE_SIZE_BYTES, DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR, DUMPS_DIR,
THUMBNAILS_DIR, THUMBNAILS_DIR,
VALIDATION,
} from "../config.ts"; } from "../config.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts"; import { relinkBacklinks } from "./backlink-service.ts";
@@ -46,6 +47,25 @@ function isAllowedMime(mime: string): boolean {
DUMP_ALLOWED_MIME_TYPES.has(mime); DUMP_ALLOWED_MIME_TYPES.has(mime);
} }
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
// the multipart ones (file create and replace) arrive here unchecked.
function assertFileDumpText(title?: string, comment?: string): void {
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
);
}
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
);
}
}
function titleFromUrl(url: string): string { function titleFromUrl(url: string): string {
try { try {
return new URL(url).hostname.replace(/^www\./, ""); return new URL(url).hostname.replace(/^www\./, "");
@@ -127,6 +147,7 @@ export async function createFileDump(
title?: string, title?: string,
categoryIds: string[] = [], categoryIds: string[] = [],
): Promise<Dump> { ): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) { if (!isAllowedMime(file.type)) {
throw new APIException( throw new APIException(
APIErrorCode.BAD_REQUEST, APIErrorCode.BAD_REQUEST,
@@ -391,6 +412,8 @@ export async function updateDump(
} }
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
notifyMentions( notifyMentions(
dump.userId, dump.userId,
@@ -468,6 +491,8 @@ export async function updateDump(
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
notifyMentions( notifyMentions(
dump.userId, dump.userId,
@@ -489,6 +514,7 @@ export async function replaceFileDump(
title?: string, title?: string,
categoryIds?: string[], categoryIds?: string[],
): Promise<Dump> { ): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) { if (!isAllowedMime(file.type)) {
throw new APIException( throw new APIException(
APIErrorCode.BAD_REQUEST, APIErrorCode.BAD_REQUEST,

View File

@@ -60,10 +60,26 @@ export function validateInvite(token: string): string {
} }
/** /**
* Marks the token as used. Call this only after the user has been created. * Validates the token and marks it used in one step, returning the inviterId.
* Claim before the (async) account creation, not after: checking first and
* marking later lets parallel registrations all pass the check on one invite.
*/ */
export function redeemInvite(token: string): void { export function claimInvite(token: string): string {
db.prepare( const inviterId = validateInvite(token);
`UPDATE invites SET used_at = ? WHERE token = ?;`, const result = db.prepare(
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
).run(new Date().toISOString(), token); ).run(new Date().toISOString(), token);
if (result.changes === 0) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Invite already used",
);
}
return inviterId;
}
/** Hands a claimed invite back when the account it was claimed for failed. */
export function releaseInvite(token: string): void {
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
} }

View File

@@ -13,6 +13,7 @@ import {
VALIDATION, VALIDATION,
} from "../config.ts"; } from "../config.ts";
import { marked } from "marked"; import { marked } from "marked";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
const RESET_TOKEN_TTL_HOURS = 1; const RESET_TOKEN_TTL_HOURS = 1;
@@ -21,7 +22,12 @@ const RESET_TOKEN_TTL_HOURS = 1;
* sends the reset link. Always resolves without throwing so callers can return * sends the reset link. Always resolves without throwing so callers can return
* 200 unconditionally (no user enumeration). * 200 unconditionally (no user enumeration).
*/ */
// Per address: enough to retry a lost email, not enough to flood an inbox.
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
export async function requestPasswordReset(email: string): Promise<void> { export async function requestPasswordReset(email: string): Promise<void> {
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
const user = getUserByEmail(email); const user = getUserByEmail(email);
if (!user) return; if (!user) return;
@@ -93,7 +99,12 @@ export async function resetPassword(
); );
} }
if (row.used_at !== null) { // Consume the link before the (async) password hash: checking used_at and
// marking it later would let parallel submissions all pass the check.
const claimed = db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
).run(new Date().toISOString(), token);
if (claimed.changes === 0) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
409, 409,
@@ -101,9 +112,11 @@ export async function resetPassword(
); );
} }
try {
await updateUser(payload.userId, { password: newPassword }); await updateUser(payload.userId, { password: newPassword });
} catch (err) {
db.prepare( db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`)
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`, .run(token);
).run(new Date().toISOString(), token); throw err;
}
} }

View File

@@ -206,7 +206,7 @@ export function updatePlaylist(
); );
linkAttachments(newDescription, playlist.id); linkAttachments(newDescription, playlist.id);
} }
broadcastPlaylistUpdated(updated); broadcastPlaylistUpdated(updated, playlist.isPublic);
return updated; return updated;
} }
@@ -240,7 +240,7 @@ export function setPlaylistImage(
playlist.id, playlist.id,
); );
const updated = getPlaylistById(playlist.id); const updated = getPlaylistById(playlist.id);
broadcastPlaylistUpdated(updated); broadcastPlaylistUpdated(updated, playlist.isPublic);
return updated; return updated;
} }
@@ -256,6 +256,17 @@ export function addDumpToPlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
// Only a dump the requester can see — someone else's private dump would
// otherwise land in the playlist and its title in followers' notifications.
const dump = db.prepare(
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as
| { title: string; is_private: number; user_id: string }
| undefined;
if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
const minRow = db.prepare( const minRow = db.prepare(
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`, `SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlist.id) as { min_pos: number | null } | undefined; ).get(playlist.id) as { min_pos: number | null } | undefined;
@@ -283,20 +294,16 @@ export function addDumpToPlaylist(
const dumpIds = getCurrentDumpIds(playlist.id); const dumpIds = getCurrentDumpIds(playlist.id);
broadcastPlaylistDumpsUpdated(playlist, dumpIds); broadcastPlaylistDumpsUpdated(playlist, dumpIds);
if (playlist.isPublic) { // Followers can't open a private dump, even the owner's own.
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( if (playlist.isPublic && !dump.is_private) {
dumpId,
) as { title: string } | undefined;
if (dumpRow) {
notifyPlaylistFollowersNewDump( notifyPlaylistFollowersNewDump(
playlist.id, playlist.id,
playlist.title, playlist.title,
dumpId, dumpId,
dumpRow.title, dump.title,
); );
} }
} }
}
export function removeDumpFromPlaylist( export function removeDumpFromPlaylist(
playlistId: string, playlistId: string,

View File

@@ -4,6 +4,7 @@ import { getDump } from "../dump-service.ts";
import { getUserByUsername } from "../user-service.ts"; import { getUserByUsername } from "../user-service.ts";
import { getPlaylistById } from "../playlist-service.ts"; import { getPlaylistById } from "../playlist-service.ts";
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts"; import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
import { isSelfUrl } from "../../lib/self-url.ts";
const DUMP_RE = /^\/dumps\/([^/]+)$/; const DUMP_RE = /^\/dumps\/([^/]+)$/;
const USER_RE = /^\/users\/([^/]+)$/; const USER_RE = /^\/users\/([^/]+)$/;
@@ -22,10 +23,7 @@ export const selfProvider: RichContentProvider = {
matches(url: string): boolean { matches(url: string): boolean {
try { try {
const { pathname } = new URL(url); return isSelfUrl(new URL(url));
return DUMP_RE.test(pathname) ||
USER_RE.test(pathname) ||
PLAYLIST_RE.test(pathname);
} catch { } catch {
return false; return false;
} }

View File

@@ -4,6 +4,11 @@ import { bandcampProvider } from "./providers/bandcamp.ts";
import { soundcloudProvider } from "./providers/soundcloud.ts"; import { soundcloudProvider } from "./providers/soundcloud.ts";
import { selfProvider } from "./providers/self.ts"; import { selfProvider } from "./providers/self.ts";
import { genericProvider } from "./providers/generic.ts"; import { genericProvider } from "./providers/generic.ts";
import {
isPrivateHostname,
resolvePublicAddress,
} from "../lib/public-address.ts";
import { isSelfUrl } from "../lib/self-url.ts";
export interface RichContentProvider { export interface RichContentProvider {
name: string; name: string;
@@ -34,23 +39,35 @@ const FETCH_HEADERS = {
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
}; };
const MAX_REDIRECTS = 5;
/** /**
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response. * Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
* Used as a last resort when Deno's rustls rejects the server's TLS certificate * Used as a last resort when Deno's rustls rejects the server's TLS certificate
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). * due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
* Returns null if curl is unavailable or exits non-zero. * Returns null if curl is unavailable or exits non-zero.
*
* `address` pins the connection to the IP already checked by
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
* back with its Location so fetchWithTimeout can check the next hop too.
*/ */
async function fetchViaCurl( async function fetchViaCurl(
url: string, url: URL,
address: string,
timeoutMs: number, timeoutMs: number,
): Promise<Response | null> { ): Promise<Response | null> {
const tmpPath = await Deno.makeTempFile(); const tmpPath = await Deno.makeTempFile();
const port = url.port || (url.protocol === "https:" ? "443" : "80");
const pinned = address.includes(":") ? `[${address}]` : address;
try { try {
const { code, stdout } = await new Deno.Command("curl", { const { code, stdout } = await new Deno.Command("curl", {
args: [ args: [
"--silent", "--silent",
"--insecure", "--insecure",
"--location", "--proto",
"=http,https",
"--resolve",
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
"--max-time", "--max-time",
String(Math.ceil(timeoutMs / 1000)), String(Math.ceil(timeoutMs / 1000)),
"--user-agent", "--user-agent",
@@ -62,8 +79,8 @@ async function fetchViaCurl(
"--output", "--output",
tmpPath, tmpPath,
"--write-out", "--write-out",
"%{content_type}", "%{http_code}\n%{redirect_url}\n%{content_type}",
url, url.href,
], ],
stdout: "piped", stdout: "piped",
stderr: "null", stderr: "null",
@@ -71,8 +88,18 @@ async function fetchViaCurl(
if (code !== 0) return null; if (code !== 0) return null;
const contentType = new TextDecoder().decode(stdout).trim(); const [status, location, contentType = ""] = new TextDecoder()
.decode(stdout).split("\n").map((l) => l.trim());
const statusCode = Number(status);
if (!(statusCode >= 200 && statusCode <= 599)) return null;
const headers = new Headers();
if (location) headers.set("location", location);
if (contentType) headers.set("content-type", contentType);
const bytes = await Deno.readFile(tmpPath); const bytes = await Deno.readFile(tmpPath);
if (!contentType.startsWith("text/")) {
return new Response(bytes, { status: statusCode, headers });
}
// Decode using the charset declared in the Content-Type header so that // Decode using the charset declared in the Content-Type header so that
// pages served in ISO-8859-1, windows-1252, etc. are read correctly. // pages served in ISO-8859-1, windows-1252, etc. are read correctly.
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
@@ -82,7 +109,8 @@ async function fetchViaCurl(
} catch { } catch {
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes); bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
} }
return new Response(bodyText, { headers: { "content-type": "text/html" } }); headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
return new Response(bodyText, { status: statusCode, headers });
} catch { } catch {
return null; return null;
} finally { } finally {
@@ -90,25 +118,20 @@ async function fetchViaCurl(
} }
} }
export async function fetchWithTimeout( /** One request, no redirects followed. */
url: string, async function fetchOnce(
timeoutMs = 5000, url: URL,
address: string,
timeoutMs: number,
): Promise<Response> { ): Promise<Response> {
async function attempt(): Promise<Response> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
return await fetch(url, { return await fetch(url, {
signal: controller.signal, signal: controller.signal,
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
redirect: "manual",
}); });
} finally {
clearTimeout(timer);
}
}
try {
return await attempt();
} catch (err) { } catch (err) {
if (!(err instanceof TypeError && err.message.includes("certificate"))) { if (!(err instanceof TypeError && err.message.includes("certificate"))) {
throw err; throw err;
@@ -118,10 +141,37 @@ export async function fetchWithTimeout(
// or an unsupported signature algorithm) and offers no way to override // or an unsupported signature algorithm) and offers no way to override
// verification programmatically. Fall back to a `curl --insecure` // verification programmatically. Fall back to a `curl --insecure`
// subprocess, which uses its own TLS stack. // subprocess, which uses its own TLS stack.
const curlRes = await fetchViaCurl(url, timeoutMs); const curlRes = await fetchViaCurl(url, address, timeoutMs);
if (curlRes) return curlRes; if (curlRes) return curlRes;
throw err; throw err;
} finally {
clearTimeout(timer);
}
}
/**
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
* must resolve to a public address (see resolvePublicAddress), so a link or a
* redirect can't point the server at its own network.
*/
export async function fetchWithTimeout(
url: string,
timeoutMs = 5000,
): Promise<Response> {
let current = new URL(url);
for (let hop = 0;; hop++) {
if (current.protocol !== "http:" && current.protocol !== "https:") {
throw new TypeError(`Unsupported protocol ${current.protocol}`);
}
const address = await resolvePublicAddress(current);
const res = await fetchOnce(current, address, timeoutMs);
const location = res.headers.get("location");
if (res.status < 300 || res.status >= 400 || !location) return res;
await res.body?.cancel();
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
current = new URL(location, current);
} }
} }
@@ -529,24 +579,14 @@ export function extractLinkHref(
return undefined; return undefined;
} }
function isPrivateHost(hostname: string): boolean {
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
if (hostname === "localhost" || hostname === "::1") return true;
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
}
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
export function isValidHttpUrl(raw: string): boolean { export function isValidHttpUrl(raw: string): boolean {
try { try {
const u = new URL(raw); const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return false; if (u.protocol !== "http:" && u.protocol !== "https:") return false;
// Allow private hosts for self-referential gerbeur URLs — they are // This site's own pages are fine even on a private host (e.g. localhost in
// resolved directly from the DB by selfProvider, no outbound HTTP needed. // development): selfProvider reads them from the DB, no HTTP involved.
if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) { // Every actual fetch is checked again, after DNS, in fetchWithTimeout.
return false; return !isPrivateHostname(u) || isSelfUrl(u);
}
return true;
} catch { } catch {
return false; return false;
} }

View File

@@ -6,7 +6,7 @@ import {
type User, type User,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
import { disconnectUser } from "./ws-service.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts";
import { hashPassword } from "../lib/jwt.ts"; import { hashPassword } from "../lib/jwt.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
@@ -36,8 +36,11 @@ export async function createUser(
); );
} }
assertEmailAvailable(request.email);
const passwordHash = await hashPassword(request.password); const passwordHash = await hashPassword(request.password);
try {
db.prepare( db.prepare(
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email) `INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
VALUES (?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?);`,
@@ -50,6 +53,18 @@ export async function createUser(
inviterId, inviterId,
request.email, request.email,
); );
} catch (err) {
// The check above ran before the hash yielded, so a parallel registration
// can still take the name first; the UNIQUE constraint is the real guard.
if (err instanceof Error && err.message.includes("UNIQUE")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Username already exists",
);
}
throw err;
}
return { return {
id: userId, id: userId,
@@ -85,6 +100,23 @@ export function getUserByUsername(username: string): User {
return userRowToApi(userRow); return userRowToApi(userRow);
} }
/**
* Password resets are looked up by email, so an address can belong to one
* account only — otherwise which account a reset link resets is up to row order.
*/
function assertEmailAvailable(email: string, exceptUserId?: string): void {
const row = db.prepare(
`SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`,
).get(email, exceptUserId ?? "");
if (row) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Email already in use",
);
}
}
export function getUserByEmail(email: string): User | null { export function getUserByEmail(email: string): User | null {
const userRow = db.prepare( const userRow = db.prepare(
`${USER_SELECT} WHERE u.email = ?`, `${USER_SELECT} WHERE u.email = ?`,
@@ -141,6 +173,9 @@ export async function updateUser(
const user = getUserById(userId); const user = getUserById(userId);
const { password, description, email, ...requestFields } = request; const { password, description, email, ...requestFields } = request;
if (email !== undefined && email !== user.email) {
assertEmailAvailable(email, userId);
}
const now = new Date(); const now = new Date();
const updatedUser: User = { const updatedUser: User = {
@@ -170,6 +205,15 @@ export async function updateUser(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
} }
// A new password ends every session opened with the old one.
if (password) {
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
now.toISOString(),
userId,
);
closeUserSockets(userId, "Password changed");
}
if (updatedUser.description) { if (updatedUser.description) {
linkAttachments(updatedUser.description, userId); linkAttachments(updatedUser.description, userId);
} }

View File

@@ -2,7 +2,26 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts"; import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts";
export function castVote(dumpId: string, userId: string): number { export interface VoteResult {
voteCount: number;
/** Private dumps' vote counts go to their owner only. */
isPrivate: boolean;
}
// Visibility of the dump, or a 404 when the user can't see it — voting must not
// confirm that a private dump exists.
function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
const row = db.prepare(
`SELECT is_private, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as { is_private: number; user_id: string } | undefined;
if (!row || (row.is_private && row.user_id !== userId)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
return { isPrivate: Boolean(row.is_private) };
}
export function castVote(dumpId: string, userId: string): VoteResult {
const { isPrivate } = visibleDump(dumpId, userId);
let voteCount: number; let voteCount: number;
try { try {
db.exec("BEGIN;"); db.exec("BEGIN;");
@@ -34,10 +53,16 @@ export function castVote(dumpId: string, userId: string): number {
try { try {
notifyDumpOwnerUpvote(userId, dumpId); notifyDumpOwnerUpvote(userId, dumpId);
} catch { /* ignore */ } } catch { /* ignore */ }
return voteCount; return { voteCount, isPrivate };
} }
export function removeVote(dumpId: string, userId: string): number { // No visibility check: taking back a vote on a dump that has since gone
// private is harmless, and it's the voter's own row.
export function removeVote(dumpId: string, userId: string): VoteResult {
const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get(
dumpId,
) as { is_private: number } | undefined;
const isPrivate = Boolean(row?.is_private);
try { try {
db.exec("BEGIN;"); db.exec("BEGIN;");
const result = db.prepare( const result = db.prepare(
@@ -50,11 +75,11 @@ export function removeVote(dumpId: string, userId: string): number {
db.prepare( db.prepare(
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`, `UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
).run(dumpId); ).run(dumpId);
const row = db.prepare( const count = db.prepare(
`SELECT vote_count FROM dumps WHERE id = ?;`, `SELECT vote_count FROM dumps WHERE id = ?;`,
).get(dumpId) as { vote_count: number } | undefined; ).get(dumpId) as { vote_count: number } | undefined;
db.exec("COMMIT;"); db.exec("COMMIT;");
return row?.vote_count ?? 0; return { voteCount: count?.vote_count ?? 0, isPrivate };
} catch (err) { } catch (err) {
if (err instanceof APIException) throw err; if (err instanceof APIException) throw err;
db.exec("ROLLBACK;"); db.exec("ROLLBACK;");

View File

@@ -83,6 +83,18 @@ export function disconnectUser(userId: string): void {
} }
} }
/**
* Drops the user's sockets without logging anyone out: each reconnects with
* whatever token it holds, and /ws turns a revoked one into a force_logout.
* Used on a password change, where the tab that made the change has already
* swapped in a fresh token and must stay signed in.
*/
export function closeUserSockets(userId: string, reason: string): void {
for (const client of clients) {
if (client.userId === userId) client.socket.close(1000, reason);
}
}
export function broadcastPresence(): void { export function broadcastPresence(): void {
const users = getOnlineUsers(); const users = getOnlineUsers();
for (const client of clients) { for (const client of clients) {
@@ -108,13 +120,16 @@ export function broadcastDumpDeleted(dumpId: string): void {
} }
} }
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
export function broadcastVoteUpdate( export function broadcastVoteUpdate(
dumpId: string, dumpId: string,
voteCount: number, voteCount: number,
voterId: string, voterId: string,
action: "cast" | "remove", action: "cast" | "remove",
onlyUserId?: string,
): void { ): void {
for (const client of clients) { for (const client of clients) {
if (onlyUserId && client.userId !== onlyUserId) continue;
send(client.socket, { send(client.socket, {
type: "votes_update", type: "votes_update",
dumpId, dumpId,
@@ -125,13 +140,16 @@ export function broadcastVoteUpdate(
} }
} }
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
export function broadcastCommentLikeUpdate( export function broadcastCommentLikeUpdate(
commentId: string, commentId: string,
likeCount: number, likeCount: number,
likerId: string, likerId: string,
action: "cast" | "remove", action: "cast" | "remove",
onlyUserId?: string,
): void { ): void {
for (const client of clients) { for (const client of clients) {
if (onlyUserId && client.userId !== onlyUserId) continue;
send(client.socket, { send(client.socket, {
type: "comment_likes_update", type: "comment_likes_update",
commentId, commentId,
@@ -157,12 +175,17 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
} }
export function broadcastPlaylistUpdated(playlist: Playlist): void { // `wasPublic`: the visibility before this change. A playlist that just went
// Broadcast to ALL clients so non-owners can react to visibility changes // private still goes to everyone, so non-owners can drop it from their views;
// (e.g. remove a now-private playlist from their feed). // one that was and stays private goes to its owner alone.
for (const client of clients) { export function broadcastPlaylistUpdated(
send(client.socket, { type: "playlist_updated", playlist }); playlist: Playlist,
} wasPublic: boolean,
): void {
sendToPlaylistAudience(
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
{ type: "playlist_updated", playlist },
);
} }
export function broadcastPlaylistDeleted( export function broadcastPlaylistDeleted(
@@ -305,44 +328,7 @@ export function isUserChatOpen(userId: string): boolean {
return false; return false;
} }
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket, export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
// so opening more tabs doesn't buy more throughput.
interface Bucket {
tokens: number;
at: number;
}
const buckets = new Map<string, Bucket>();
export interface RateLimit {
/** Burst size. */
capacity: number;
/** Tokens regained per second. */
refill: number;
}
/** Takes one token for `key`; false when the bucket is empty. */
export function takeToken(key: string, limit: RateLimit): boolean {
const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
bucket.tokens = Math.min(
limit.capacity,
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
);
bucket.at = now;
buckets.set(key, bucket);
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
return true;
}
// Full buckets carry no information; drop them so the map tracks only users
// who've been active lately.
setInterval(() => {
const cutoff = Date.now() - 5 * 60_000;
for (const [key, bucket] of buckets) {
if (bucket.at < cutoff) buckets.delete(key);
}
}, 60_000);
export function handleClientPong(client: WsClient): void { export function handleClientPong(client: WsClient): void {
client.pongReceived = true; client.pongReceived = true;

View File

@@ -5920,11 +5920,40 @@ body.has-player .chat-fab {
); );
} }
/* The scrim is tuned for white ink at rest; the hover colour is whatever the
theme's accent is, which over a busy or bright thumbnail can sink into it.
So a pointed-at title picks up a backing of its own — a dark glass plate by
default, reskinned per theme through the --journal-title-hover-* props. The
padding is always there and cancelled by an equal negative margin, so the
plate grows around the text instead of shifting it; `align-self` keeps it
hugging the headline rather than spanning the whole card. */
.journal-card--image .journal-card-title { .journal-card--image .journal-card-title {
--journal-title-hover-pad-y: 0.12em;
--journal-title-hover-pad-x: 0.35em;
--journal-title-hover-bg: rgb(0 0 0 / 0.66);
--journal-title-hover-color: var(--color-accent);
--journal-title-hover-radius: var(--radius-sm);
--journal-title-hover-shadow: none;
--journal-title-hover-filter: none;
--journal-title-hover-text-shadow: none;
align-self: flex-start;
max-width: calc(100% + 2 * var(--journal-title-hover-pad-x));
padding: var(--journal-title-hover-pad-y) var(--journal-title-hover-pad-x);
margin: calc(-1 * var(--journal-title-hover-pad-y))
calc(-1 * var(--journal-title-hover-pad-x));
border-radius: var(--journal-title-hover-radius);
color: #fff; color: #fff;
transition:
color var(--dur-base),
background-color var(--dur-base),
box-shadow var(--dur-base);
} }
.journal-card--image .journal-card-title:hover { .journal-card--image .journal-card-title:hover {
color: var(--color-accent); color: var(--journal-title-hover-color);
background-color: var(--journal-title-hover-bg);
box-shadow: var(--journal-title-hover-shadow);
backdrop-filter: var(--journal-title-hover-filter);
text-shadow: var(--journal-title-hover-text-shadow);
} }
.journal-card--image .journal-card-meta { .journal-card--image .journal-card-meta {
color: rgba(255, 255, 255, 0.78); color: rgba(255, 255, 255, 0.78);

View File

@@ -37,27 +37,22 @@ export function AddToPlaylistModal(
const toggleMembership = async (membership: PlaylistMembership) => { const toggleMembership = async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership; const { playlist, hasDump } = membership;
if (hasDump) { // The box only changes once the server agrees; a refusal or a dropped
await authFetch( // connection leaves it showing the truth.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "DELETE" }, { method: hasDump ? "DELETE" : "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
); );
if (!res.ok) return;
} catch {
return;
} }
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}; };
return ( return (

View File

@@ -25,7 +25,7 @@ interface Values {
} }
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch } = useAuth(); const { authFetch, updateSession } = useAuth();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const form = useApiForm<Values>({ const form = useApiForm<Values>({
mode: "onChange", mode: "onChange",
@@ -33,13 +33,16 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
}); });
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => { const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
await expectOk( // The change signs out every session opened with the old password, this
// one included, so keep the fresh tokens it hands back.
const tokens = await expectOk<{ token: string; mediaToken: string }>(
await authFetch(`${API_URL}/api/users/me/change-password`, { await authFetch(`${API_URL}/api/users/me/change-password`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}), }),
); );
updateSession(tokens);
setDone(true); setDone(true);
}); });

View File

@@ -22,6 +22,7 @@ import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { import {
expectOk, expectOk,
FormError, FormError,
@@ -30,13 +31,6 @@ import {
useApiForm, useApiForm,
} from "./form/index.ts"; } from "./form/index.ts";
function authHeaders(token: string | null, json = true): HeadersInit {
const headers: Record<string, string> = {};
if (json) headers["Content-Type"] = "application/json";
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
interface CommentFormProps { interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */ /** Async submit; throw to surface a root error, resolve to clear/close. */
onSubmit: (body: string) => Promise<void>; onSubmit: (body: string) => Promise<void>;
@@ -208,13 +202,13 @@ function CommentNode({
const editEditorRef = useRef<TextEditorHandle>(null); const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth();
const children = tree.get(comment.id) ?? []; const children = tree.get(comment.id) ?? [];
async function handleReply(body: string) { async function handleReply(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, { const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST", method: "POST",
headers: authHeaders(token),
body: JSON.stringify( body: JSON.stringify(
{ body, parentId: comment.id } satisfies CreateCommentRequest, { body, parentId: comment.id } satisfies CreateCommentRequest,
), ),
@@ -225,9 +219,8 @@ function CommentNode({
async function handleDelete() { async function handleDelete() {
if (!token) return; if (!token) return;
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => null); }).catch(() => null);
if (res?.ok) { if (res?.ok) {
onCommentDeleted(comment.id); onCommentDeleted(comment.id);
@@ -235,9 +228,8 @@ function CommentNode({
} }
async function handleEditSave(body: string) { async function handleEditSave(body: string) {
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH", method: "PATCH",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies UpdateCommentRequest), body: JSON.stringify({ body } satisfies UpdateCommentRequest),
}); });
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res))); onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
@@ -452,11 +444,11 @@ export function CommentThread({
}: CommentThreadProps) { }: CommentThreadProps) {
const tree = useMemo(() => buildTree(comments), [comments]); const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? []; const roots = tree.get("root") ?? [];
const { authFetch } = useAuth();
async function handleTopLevelSubmit(body: string) { async function handleTopLevelSubmit(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, { const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST", method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies CreateCommentRequest), body: JSON.stringify({ body } satisfies CreateCommentRequest),
}); });
onCommentCreated(deserializeComment(await expectOk<RawComment>(res))); onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));

View File

@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useScrollLock } from "../hooks/useScrollLock"; import { useScrollLock } from "../hooks/useScrollLock.ts";
interface ConfirmModalProps { interface ConfirmModalProps {
message: string; message: string;

View File

@@ -19,8 +19,8 @@ interface DumpCardProps {
voteCount: number; voteCount: number;
voted: boolean; voted: boolean;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
className?: string; className?: string;
isOwner?: boolean; isOwner?: boolean;
} }
@@ -30,7 +30,7 @@ export function DumpCard(
DumpCardProps, DumpCardProps,
) { ) {
const { cardProps, handledAsNewTab } = useDumpCardLink(dump); const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { token } = useAuth(); const { mediaToken } = useAuth();
const { byId } = useCategories(); const { byId } = useCategories();
const dumpCategories = (dump.categoryIds ?? []) const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id)) .map((id) => byId.get(id))
@@ -59,7 +59,7 @@ export function DumpCard(
compact compact
dumpHref={dumpUrl(dump)} dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: undefined} : undefined}
/> />
) )

View File

@@ -386,12 +386,19 @@ export function DumpCreateModal(
// same timing for the user, just not in the same tick. // same timing for the user, just not in the same tick.
queueMicrotask(() => setUrlFetchState({ status: "loading" })); queueMicrotask(() => setUrlFetchState({ status: "loading" }));
// A preview can take seconds (the server fetches the page), so the link
// may have changed by the time one lands: the answer for the old link must
// not show, or suggest its title, under the new one.
const controller = new AbortController();
const { signal } = controller;
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl); const encoded = encodeURIComponent(canonicalUrl);
fetch(`${API_URL}/api/preview?url=${encoded}`) authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal })
.then((res) => res.json()) .then((res) => res.json())
.then((body) => { .then((body) => {
if (signal.aborted) return;
const data = body.success const data = body.success
? body.data as UrlPreviewResponse ? body.data as UrlPreviewResponse
: undefined; : undefined;
@@ -402,14 +409,16 @@ export function DumpCreateModal(
}); });
}) })
.catch(() => { .catch(() => {
if (signal.aborted) return;
setUrlFetchState({ status: "done", reached: false, richContent: null }); setUrlFetchState({ status: "done", reached: false, richContent: null });
}); });
// Signed in, so that a private dump of the poster's own counts as a // Signed in, so that a private dump of the poster's own counts as a
// duplicate for them and for nobody else. // duplicate for them and for nobody else.
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`) authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
.then((res) => res.json()) .then((res) => res.json())
.then((body) => { .then((body) => {
if (signal.aborted) return;
setDuplicateState({ setDuplicateState({
url: canonicalUrl, url: canonicalUrl,
matches: body.success matches: body.success
@@ -417,13 +426,16 @@ export function DumpCreateModal(
: [], : [],
}); });
}) })
.catch(() => .catch(() => {
setDuplicateState({ url: canonicalUrl, matches: [] }) if (!signal.aborted) {
); setDuplicateState({ url: canonicalUrl, matches: [] });
}
});
}, 600); }, 600);
return () => { return () => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
controller.abort();
}; };
}, [canonicalUrl, authFetch]); }, [canonicalUrl, authFetch]);
@@ -613,27 +625,22 @@ export function DumpCreateModal(
const toggleMembership = async (membership: PlaylistMembership) => { const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return; if (!createdDump) return;
const { playlist, hasDump } = membership; const { playlist, hasDump } = membership;
if (hasDump) { // The box only changes once the server agrees; a refusal or a dropped
await authFetch( // connection leaves it showing the truth.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "DELETE" }, { method: hasDump ? "DELETE" : "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
); );
if (!res.ok) return;
} catch {
return;
} }
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}; };
const canProceed = !!file || !!canonicalUrl; const canProceed = !!file || !!canonicalUrl;

View File

@@ -52,7 +52,7 @@ function filePlayerItem(
function AudioFilePreview( function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) { ) {
const { token } = useAuth(); const { mediaToken: token } = useAuth();
const { current, playing, currentTime, duration, play, togglePlay, seekTo } = const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext); useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null); const [peaks, setPeaks] = useState<Float32Array | null>(null);
@@ -174,7 +174,7 @@ export default function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps, { dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) { ) {
const { current, playing, play, togglePlay } = useContext(PlayerContext); const { current, playing, play, togglePlay } = useContext(PlayerContext);
const { token } = useAuth(); const { mediaToken: token } = useAuth();
const fileUrl = dumpFileUrl(dump, token); const fileUrl = dumpFileUrl(dump, token);
const mime = dump.fileMime ?? ""; const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;

View File

@@ -24,8 +24,8 @@ interface JournalCardProps {
voteCount: number; voteCount: number;
voted: boolean; voted: boolean;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
isOwner?: boolean; isOwner?: boolean;
} }
@@ -34,7 +34,7 @@ export function JournalCard(
JournalCardProps, JournalCardProps,
) { ) {
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump); const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { token } = useAuth(); const { mediaToken } = useAuth();
const { playRichContent } = usePlayRichContent(); const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !isDumpVisited(dump.id);
@@ -49,12 +49,12 @@ export function JournalCard(
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated // Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// still here too, rather than degrading to a text card with a 🎬. // still here too, rather than degrading to a text card with a 🎬.
const thumbnailUrl = dump.thumbnailMime const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file" : dump.kind === "file"
? (dump.fileMime?.startsWith("image/") ? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token) ? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/") : dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: null) : null)
: (dump.richContent?.thumbnailUrl ?? null); : (dump.richContent?.thumbnailUrl ?? null);

View File

@@ -8,8 +8,8 @@ interface LikeButtonProps {
count: number; count: number;
liked: boolean; liked: boolean;
disabled?: boolean; disabled?: boolean;
onLike: (commentId: string) => void; onLike: (commentId: string, shownCount?: number) => void;
onUnlike: (commentId: string) => void; onUnlike: (commentId: string, shownCount?: number) => void;
} }
export function LikeButton( export function LikeButton(
@@ -25,7 +25,8 @@ export function LikeButton(
className={`comment-action-btn${ className={`comment-action-btn${
liked ? " comment-action-btn--liked" : "" liked ? " comment-action-btn--liked" : ""
}`} }`}
onClick={() => liked ? onUnlike(commentId) : onLike(commentId)} onClick={() =>
liked ? onUnlike(commentId, count) : onLike(commentId, count)}
disabled={disabled} disabled={disabled}
aria-label={liked ? t`Remove like` : t`Like`} aria-label={liked ? t`Remove like` : t`Like`}
title={disabled ? t`Log in to like` : undefined} title={disabled ? t`Log in to like` : undefined}

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -9,13 +8,57 @@ interface MarkdownProps {
inline?: boolean; inline?: boolean;
} }
const REMARK_PLUGINS = [remarkGfm]; // The mention rule the server notifies by (see utils/chatText.ts): an `@` not
// preceded by a word character, so an email address isn't a mention.
const MENTION_RE = /(?<!\w)@(\w+)/g;
// Convert bare @username (not already inside a markdown link) to clickable links interface MdNode {
function preprocessMentions(text: string): string { type: string;
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)"); value?: string;
url?: string;
children?: MdNode[];
} }
/**
* Turns bare @username into a link to that user. Works on the parsed tree
* rather than the source text, so code keeps its @s verbatim and a URL that
* contains one (`https://mastodon.social/@bob`) isn't split in two.
*/
function remarkMentions() {
const walk = (node: MdNode) => {
if (!node.children) return;
// Link text is already a link, whatever it says.
if (node.type === "link" || node.type === "linkReference") return;
node.children = node.children.flatMap((child) => {
if (child.type !== "text" || !child.value?.includes("@")) {
walk(child);
return [child];
}
const out: MdNode[] = [];
let last = 0;
for (const m of child.value.matchAll(MENTION_RE)) {
if (m.index > last) {
out.push({ type: "text", value: child.value.slice(last, m.index) });
}
out.push({
type: "link",
url: `/users/${m[1]}`,
children: [{ type: "text", value: m[0] }],
});
last = m.index + m[0].length;
}
if (last === 0) return [child];
if (last < child.value.length) {
out.push({ type: "text", value: child.value.slice(last) });
}
return out;
});
};
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions];
type Components = React.ComponentProps<typeof ReactMarkdown>["components"]; type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render // Static components objects — defined once at module scope to avoid recreation on every render
@@ -43,8 +86,6 @@ const INLINE_MARKDOWN_COMPONENTS: Components = {
export function Markdown( export function Markdown(
{ children, className, inline = false }: MarkdownProps, { children, className, inline = false }: MarkdownProps,
) { ) {
const processed = useMemo(() => preprocessMentions(children), [children]);
return ( return (
<div <div
className={`md${className ? ` ${className}` : ""}${ className={`md${className ? ` ${className}` : ""}${
@@ -57,7 +98,7 @@ export function Markdown(
? INLINE_MARKDOWN_COMPONENTS ? INLINE_MARKDOWN_COMPONENTS
: MARKDOWN_COMPONENTS} : MARKDOWN_COMPONENTS}
> >
{processed} {children}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { type ReactNode, useCallback, useEffect, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useScrollLock } from "../hooks/useScrollLock"; import { useScrollLock } from "../hooks/useScrollLock.ts";
interface ModalProps { interface ModalProps {
title: string; title: string;

View File

@@ -8,8 +8,8 @@ interface VoteButtonProps {
count: number; count: number;
voted: boolean; voted: boolean;
disabled?: boolean; disabled?: boolean;
onCast: (dumpId: string) => void; onCast: (dumpId: string, shownCount?: number) => void;
onRemove: (dumpId: string) => void; onRemove: (dumpId: string, shownCount?: number) => void;
} }
export function VoteButton( export function VoteButton(
@@ -23,7 +23,7 @@ export function VoteButton(
<button <button
type="button" type="button"
className={`vote-btn${voted ? " vote-btn--active" : ""}`} className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)} onClick={() => voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
disabled={disabled} disabled={disabled}
aria-label={voted ? t`Remove vote` : t`Upvote`} aria-label={voted ? t`Remove vote` : t`Upvote`}
title={disabled ? t`Log in to vote` : undefined} title={disabled ? t`Log in to vote` : undefined}

View File

@@ -1,10 +1,10 @@
import { createContext } from "react"; import { createContext, type Dispatch, type SetStateAction } from "react";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../model.ts";
export interface AuthContextValue { export interface AuthContextValue {
authResponse: AuthResponse | null; authResponse: AuthResponse | null;
setAuthResponse: (authResponse: AuthResponse | null) => void; setAuthResponse: Dispatch<SetStateAction<AuthResponse | null>>;
} }
export const AuthContext = createContext<AuthContextValue>({ export const AuthContext = createContext<AuthContextValue>({

View File

@@ -2,48 +2,31 @@ import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import { import { type AuthResponse, deserializePublicUser } from "../model.ts";
type AuthResponse,
deserializeAuthResponse,
deserializePublicUser,
} from "../model.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import {
function isTokenExpired(token: string): boolean { AUTH_STORAGE_KEY,
try { forgetStoredSession,
const payload = JSON.parse(atob(token.split(".")[1])); readStoredSession,
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now(); storeSession,
} catch { } from "../utils/authStorage.ts";
return true;
}
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => { const [authResponse, setAuthResponse] = useState<AuthResponse | null>(
const stored = localStorage.getItem("authResponse"); readStoredSession,
if (!stored) return null; );
let parsed; // Every tab shares the stored session, so follow it: a login, logout or
try { // password change in another tab applies here too, rather than this tab
parsed = deserializeAuthResponse(JSON.parse(stored)); // carrying on with a token the server has since revoked.
} catch { useEffect(() => {
localStorage.removeItem("authResponse"); const onStorage = (e: StorageEvent) => {
return null; if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return;
} setAuthResponse(readStoredSession());
if (isTokenExpired(parsed.token)) { };
localStorage.removeItem("authResponse"); globalThis.addEventListener("storage", onStorage);
return null; return () => globalThis.removeEventListener("storage", onStorage);
} }, []);
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem("authResponse");
return null;
}
return parsed;
});
// The `exp` pre-check above only catches expiry; it cannot detect a token the // The `exp` pre-check above only catches expiry; it cannot detect a token the
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which // server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
@@ -62,8 +45,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
cache: "no-store", cache: "no-store",
}); });
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem("authResponse"); // Only this token is dead; the session may have moved on to a
setAuthResponse(null); // fresh one (another tab's password change) while we asked.
forgetStoredSession(token);
setAuthResponse((prev) => prev?.token === token ? null : prev);
return; return;
} }
// The stored user is a snapshot from login time; the role (and other // The stored user is a snapshot from login time; the role (and other
@@ -77,7 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthResponse((prev) => { setAuthResponse((prev) => {
if (!prev || prev.user.role === freshUser.role) return prev; if (!prev || prev.user.role === freshUser.role) return prev;
const updated = { ...prev, user: freshUser }; const updated = { ...prev, user: freshUser };
localStorage.setItem("authResponse", JSON.stringify(updated)); storeSession(updated);
return updated; return updated;
}); });
} }
@@ -99,6 +84,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, [token]); }, [token]);
// Sessions from before media tokens existed have none, and would keep putting
// the full session token in file and thumbnail URLs; fetch one once.
const needsMediaToken = !!token && !authResponse?.mediaToken;
useEffect(() => {
if (!token || !needsMediaToken) return;
let cancelled = false;
fetch(`${API_URL}/api/users/me/media-token`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
})
.then((res) => res.ok ? res.json() : null)
.then((json) => {
const mediaToken = json?.data?.mediaToken;
if (cancelled || typeof mediaToken !== "string") return;
setAuthResponse((prev) => {
if (!prev || prev.token !== token) return prev;
const updated = { ...prev, mediaToken };
storeSession(updated);
return updated;
});
})
.catch(() => {/* keep using the session token until next load */});
return () => {
cancelled = true;
};
}, [token, needsMediaToken]);
const value: AuthContextValue = { authResponse, setAuthResponse }; const value: AuthContextValue = { authResponse, setAuthResponse };
return ( return (

View File

@@ -5,6 +5,7 @@ import {
playerItemKey, playerItemKey,
} from "./PlayerContext.ts"; } from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts"; import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
import { useAuth } from "../hooks/useAuth.ts";
const STORAGE_KEY = "player"; const STORAGE_KEY = "player";
@@ -20,6 +21,11 @@ interface StoredSession {
queue: PlayerItem[]; queue: PlayerItem[];
index: number; index: number;
time: number; time: number;
/**
* Whose queue this is (null for a guest). File URLs carry the listener's
* media token, so a queue must never be handed to whoever signs in next.
*/
owner?: string | null;
} }
function isPlayerItem(value: unknown): value is PlayerItem { function isPlayerItem(value: unknown): value is PlayerItem {
@@ -28,13 +34,25 @@ function isPlayerItem(value: unknown): value is PlayerItem {
return kind === "embed" || kind === "file" || kind === "stream"; return kind === "embed" || kind === "file" || kind === "stream";
} }
function readSession(): { queue: PlayerItem[]; index: number; time: number } | null { function readSession(
owner: string | null,
): { queue: PlayerItem[]; index: number; time: number } | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as Record<string, unknown>;
const time = Number(parsed.time) || 0; const time = Number(parsed.time) || 0;
// Another account's queue, or one saved before queues had owners: the
// latter may be anybody's, and only file URLs hold a token worth keeping
// to themselves.
if (parsed.owner === undefined) {
const items = Array.isArray(parsed.queue) ? parsed.queue : [parsed.item];
if (items.some((i) => isPlayerItem(i) && i.kind === "file")) return null;
} else if (parsed.owner !== owner) {
return null;
}
const rawQueue = parsed.queue; const rawQueue = parsed.queue;
if (Array.isArray(rawQueue)) { if (Array.isArray(rawQueue)) {
const queue = rawQueue.filter(isPlayerItem); const queue = rawQueue.filter(isPlayerItem);
@@ -60,8 +78,10 @@ function readSession(): { queue: PlayerItem[]; index: number; time: number } | n
const RERESOLVE_COOLDOWN_MS = 30_000; const RERESOLVE_COOLDOWN_MS = 30_000;
export function PlayerProvider({ children }: { children: React.ReactNode }) { export function PlayerProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const owner = user?.id ?? null;
// Lazy initialiser: readSession runs exactly once, on first render. // Lazy initialiser: readSession runs exactly once, on first render.
const [restored] = useState(readSession); const [restored] = useState(() => readSession(owner));
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []); const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0); const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
@@ -323,7 +343,13 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify( JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession, {
v: 2,
queue,
index: queueIndex,
time: currentTime,
owner,
} satisfies StoredSession,
), ),
); );
} else { } else {
@@ -331,12 +357,23 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
} }
// currentTime intentionally omitted from deps — see pagehide writer below. // currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, queueIndex]); }, [queue, queueIndex, owner]);
// Signing out, or in as someone else, ends the listening session: the queue
// was built with the previous account's media token. Signing in keeps a
// guest's queue, which holds no token.
const [queueOwner, setQueueOwner] = useState(owner);
useEffect(() => {
if (owner === queueOwner) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueueOwner(owner);
if (queueOwner !== null) stop();
}, [owner, queueOwner, stop]);
const sessionRef = useRef<StoredSession | null>(null); const sessionRef = useRef<StoredSession | null>(null);
useEffect(() => { useEffect(() => {
sessionRef.current = queue.length > 0 sessionRef.current = queue.length > 0
? { v: 2, queue, index: queueIndex, time: currentTime } ? { v: 2, queue, index: queueIndex, time: currentTime, owner }
: null; : null;
}); });

View File

@@ -101,10 +101,11 @@ export interface WSContextValue {
typingUsers: OnlineUser[]; typingUsers: OnlineUser[];
/** Increments on each WS reconnect so pages can backfill missed events. */ /** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number; connectionEpoch: number;
castVote: (dumpId: string) => void; /** `shownCount`: the count on screen, used until a live one arrives. */
removeVote: (dumpId: string) => void; castVote: (dumpId: string, shownCount?: number) => void;
castCommentLike: (commentId: string) => void; removeVote: (dumpId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string) => void; castCommentLike: (commentId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string, shownCount?: number) => void;
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void; clearUnreadNotifications: () => void;
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;

View File

@@ -450,9 +450,20 @@ export function WSProvider({ children }: WSProviderProps) {
} }
case "dump_created": { case "dump_created": {
// Also sent when a dump goes from private to public: to everyone
// who couldn't see it, that's an arrival.
const dump = deserializeDump(msg.dump); const dump = deserializeDump(msg.dump);
setDeletedDumpIds((prev) => {
if (!prev.has(dump.id)) return prev;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
setRecentDumps((prev) => setRecentDumps((prev) =>
[dump, ...prev].slice(0, MAX_RECENT_DUMPS) [dump, ...prev.filter((d) => d.id !== dump.id)].slice(
0,
MAX_RECENT_DUMPS,
)
); );
break; break;
} }
@@ -460,19 +471,13 @@ export function WSProvider({ children }: WSProviderProps) {
case "dump_updated": { case "dump_updated": {
const dump = deserializeDump(msg.dump); const dump = deserializeDump(msg.dump);
setLastDumpEvent(dump); setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed // An edit is not an arrival: refresh a live entry in place, but
// (e.g. it was made private, and is now public again). // don't pull an old dump to the top of the feeds (going public
setDeletedDumpIds((prev) => { // comes as a `dump_created`).
if (!prev.has(dump.id)) return prev;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
// Add to live feed if not already present (private→public).
setRecentDumps((prev) => setRecentDumps((prev) =>
prev.some((d) => d.id === dump.id) prev.some((d) => d.id === dump.id)
? prev ? prev.map((d) => d.id === dump.id ? dump : d)
: [dump, ...prev].slice(0, MAX_RECENT_DUMPS) : prev
); );
break; break;
} }
@@ -745,9 +750,10 @@ export function WSProvider({ children }: WSProviderProps) {
token, token,
]); ]);
const castVote = useCallback((dumpId: string) => { const castVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = voteCountsRef.current[dumpId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId); const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted if (prevVoted) return; // already voted
@@ -778,9 +784,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const removeVote = useCallback((dumpId: string) => { const removeVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = voteCountsRef.current[dumpId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId); const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted if (!prevVoted) return; // not voted
@@ -814,9 +821,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const castCommentLike = useCallback((commentId: string) => { const castCommentLike = useCallback((commentId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = likeCountsRef.current[commentId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId); const prevLiked = myLikesRef.current.has(commentId);
if (prevLiked) return; // already liked if (prevLiked) return; // already liked
@@ -847,9 +855,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]); }, [schedulePendingLike]);
const removeCommentLike = useCallback((commentId: string) => { const removeCommentLike = useCallback((commentId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = likeCountsRef.current[commentId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId); const prevLiked = myLikesRef.current.has(commentId);
if (!prevLiked) return; // not liked if (!prevLiked) return; // not liked
@@ -1038,11 +1047,16 @@ export function WSProvider({ children }: WSProviderProps) {
const reconcileChat = useCallback( const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => { (messages: ChatMessage[], from: Date, to: Date) => {
const ids = new Set(messages.map((m) => m.id)); // In that stretch the fetched copy is the truth: a live copy of a
// message edited while the socket was down is stale, and the merge in
// the chatbox would let it win.
const byId = new Map(messages.map((m) => [m.id, m]));
setChatMessages((prev) => setChatMessages((prev) =>
prev.filter((m) => prev
ids.has(m.id) || m.createdAt < from || m.createdAt > to .filter((m) =>
byId.has(m.id) || m.createdAt < from || m.createdAt > to
) )
.map((m) => byId.get(m.id) ?? m)
); );
// Failed sends that did land (only the echo was lost) are retired. // Failed sends that did land (only the echo was lost) are retired.
const mine = messages.filter((m) => m.userId === userIdRef.current); const mine = messages.filter((m) => m.userId === userIdRef.current);

View File

@@ -3,27 +3,37 @@ import { useCallback, useContext } from "react";
import { AuthContext } from "../contexts/AuthContext.ts"; import { AuthContext } from "../contexts/AuthContext.ts";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../model.ts";
import {
function isTokenExpired(token: string): boolean { forgetStoredSession,
try { isTokenExpired,
const payload = JSON.parse(atob(token.split(".")[1])); storeSession,
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now(); } from "../utils/authStorage.ts";
} catch {
return true;
}
}
export const useAuth = () => { export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext); const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => { const login = useCallback((response: AuthResponse) => {
setAuthResponse(response); setAuthResponse(response);
localStorage.setItem("authResponse", JSON.stringify(response)); storeSession(response);
}, [setAuthResponse]); }, [setAuthResponse]);
// Ends the session this render holds. A 401 can land after the session has
// moved on to fresh tokens (a password change, here or in another tab), and
// must not sign those out.
const sessionToken = authResponse?.token;
const logout = useCallback(() => { const logout = useCallback(() => {
setAuthResponse(null); setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
localStorage.removeItem("authResponse"); forgetStoredSession(sessionToken);
}, [setAuthResponse, sessionToken]);
/** Merge into the current session, in memory and in storage. */
const updateSession = useCallback((patch: Partial<AuthResponse>) => {
setAuthResponse((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
storeSession(next);
return next;
});
}, [setAuthResponse]); }, [setAuthResponse]);
const authFetch = useCallback(async ( const authFetch = useCallback(async (
@@ -116,20 +126,43 @@ export const useAuth = () => {
return { return {
user: authResponse?.user ?? null, user: authResponse?.user ?? null,
token: authResponse?.token ?? null, token: authResponse?.token ?? null,
// For file and thumbnail URLs, where the token is visible in the query
// string. Sessions from before media tokens fall back to the session token
// until AuthProvider fetches one.
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login, login,
logout, logout,
updateSession,
authFetch, authFetch,
authUpload, authUpload,
}; };
}; };
export const useRequiredAuth = () => { export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch, authUpload } = useAuth(); const {
user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
} = useAuth();
if (!user) { if (!user) {
throw new Error( throw new Error(
"Invariant: useRequiredAuth called outside a protected route", "Invariant: useRequiredAuth called outside a protected route",
); );
} }
return { user, token, login, logout, authFetch, authUpload }; return {
user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
};
}; };

View File

@@ -16,7 +16,7 @@ export function useScrollLock() {
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
if (locks++ === 0) { if (locks++ === 0) {
if (window.innerWidth > root.clientWidth) { if (globalThis.innerWidth > root.clientWidth) {
root.style.scrollbarGutter = "stable"; root.style.scrollbarGutter = "stable";
} }
root.style.overflowY = "hidden"; root.style.overflowY = "hidden";

View File

@@ -208,6 +208,8 @@ export function deserializeInviteTreeEntry(
export interface AuthResponse { export interface AuthResponse {
token: string; token: string;
/** Read-only token for media URLs (`?token=`); absent on older sessions. */
mediaToken?: string;
user: User; user: User;
} }

View File

@@ -80,7 +80,7 @@ export function Dump() {
const [titleSaving, setTitleSaving] = useState(false); const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null); const [titleError, setTitleError] = useState<string | null>(null);
const { user, token, authFetch } = useAuth(); const { user, token, mediaToken, authFetch } = useAuth();
const { byId } = useCategories(); const { byId } = useCategories();
const { const {
voteCounts, voteCounts,
@@ -111,10 +111,9 @@ export function Dump() {
setDumpState({ status: "loading" }); setDumpState({ status: "loading" });
setOp(null); setOp(null);
try { try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { const res = await authFetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
const apiResponse = parseAPIResponse<RawDump>(await res.json()); const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) { if (!apiResponse.success) {
@@ -135,7 +134,7 @@ export function Dump() {
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, preloaded, token]); }, [selectedDump, preloaded, authFetch]);
useEffect(() => { useEffect(() => {
if (!lastDumpEvent) return; if (!lastDumpEvent) return;
@@ -154,9 +153,8 @@ export function Dump() {
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -168,15 +166,14 @@ export function Dump() {
return () => controller.abort(); return () => controller.abort();
// connectionEpoch: re-fetch on reconnect to backfill comments missed while // connectionEpoch: re-fetch on reconnect to backfill comments missed while
// the socket was down. // the socket was down.
}, [selectedDump, token, connectionEpoch]); }, [selectedDump, authFetch, connectionEpoch]);
// Fetch related dumps (backlinks) when dump loads // Fetch related dumps (backlinks) when dump loads
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/related`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -186,16 +183,15 @@ export function Dump() {
}) })
.catch(() => {}); .catch(() => {});
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, token]); }, [selectedDump, authFetch]);
// Fetch the collections this dump appears in (public ones, plus the // Fetch the collections this dump appears in (public ones, plus the
// viewer's own private ones — the server does the filtering) // viewer's own private ones — the server does the filtering)
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -208,7 +204,7 @@ export function Dump() {
// lastPlaylistEvent: adding/removing this dump, flipping a collection's // lastPlaylistEvent: adding/removing this dump, flipping a collection's
// visibility or deleting it all change the list — playlist events are rare // visibility or deleting it all change the list — playlist events are rare
// enough to just refetch on any of them. // enough to just refetch on any of them.
}, [selectedDump, token, lastPlaylistEvent]); }, [selectedDump, authFetch, lastPlaylistEvent]);
// Scroll to and highlight a comment when navigating to #comment-{id} // Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => { useEffect(() => {
@@ -497,7 +493,7 @@ export function Dump() {
richContent={dump.richContent} richContent={dump.richContent}
dumpHref={dumpUrl(dump)} dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: undefined} : undefined}
/> />
) )

View File

@@ -39,7 +39,7 @@ type DumpEditState =
export function DumpEdit() { export function DumpEdit() {
const { selectedDump } = useParams(); const { selectedDump } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { authFetch, token } = useRequiredAuth(); const { authFetch, token, mediaToken } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" }); const [state, setState] = useState<DumpEditState>({ status: "loading" });
@@ -195,12 +195,12 @@ export function DumpEdit() {
const { dump } = state; const { dump } = state;
const currentThumbnailSrc = dump.thumbnailMime const currentThumbnailSrc = dump.thumbnailMime
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file" : dump.kind === "file"
? (dump.fileMime?.startsWith("image/") ? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token) ? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/") : dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, mediaToken)
: null) : null)
: dump.richContent?.thumbnailUrl ?? null; : dump.richContent?.thumbnailUrl ?? null;

View File

@@ -19,7 +19,6 @@ import type {
Role, Role,
} from "../model.ts"; } from "../model.ts";
import { import {
deserializeAuthResponse,
deserializeDump, deserializeDump,
deserializeInviteTreeEntry, deserializeInviteTreeEntry,
deserializePublicUser, deserializePublicUser,
@@ -235,7 +234,7 @@ function buildInviteTree(
export function UserPublicProfile() { export function UserPublicProfile() {
const { username } = useParams(); const { username } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user: me, authFetch, login, logout, token } = useAuth(); const { user: me, authFetch, updateSession, logout, token } = useAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
@@ -734,13 +733,7 @@ export function UserPublicProfile() {
return; return;
} }
const storedRaw = localStorage.getItem("authResponse"); updateSession({ user: deserializePublicUser(body.data) });
if (storedRaw) {
login({
...deserializeAuthResponse(JSON.parse(storedRaw)),
user: deserializePublicUser(body.data),
});
}
setState((prev) => setState((prev) =>
prev.status === "loaded" prev.status === "loaded"
@@ -756,11 +749,7 @@ export function UserPublicProfile() {
}; };
const handleEmailSaved = (email: string) => { const handleEmailSaved = (email: string) => {
const storedRaw = localStorage.getItem("authResponse"); if (me) updateSession({ user: { ...me, email } });
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email } });
}
setEmailEditing(false); setEmailEditing(false);
}; };
@@ -1403,8 +1392,8 @@ function DumpList(
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
isOwnProfile?: boolean; isOwnProfile?: boolean;
viewAllHref: string; viewAllHref: string;
}, },
@@ -1482,8 +1471,8 @@ function UpvotedDumpList(
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
viewAllHref: string; viewAllHref: string;
}, },
) { ) {

View File

@@ -38,8 +38,8 @@ interface FollowedFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User; user: User;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
} }
@@ -50,8 +50,8 @@ interface FollowedSubFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User; user: User;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
emptyMessage: string; emptyMessage: string;
onLoadMore: () => void; onLoadMore: () => void;

View File

@@ -11,6 +11,6 @@ export interface MainFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User | null; user: User | null;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
} }

View File

@@ -190,6 +190,16 @@
--journal-break-rule: 2px solid var(--color-border); --journal-break-rule: 2px solid var(--color-border);
} }
/* A pointed-at headline over a photo becomes a label slapped on it: a solid
accent block, hard offset shadow, no glass — the same stamp every button
here is made of. */
[data-style="brutalist"] .journal-card--image .journal-card-title {
--journal-title-hover-bg: var(--color-accent);
--journal-title-hover-color: var(--color-on-accent);
--journal-title-hover-radius: 0;
--journal-title-hover-shadow: 3px 3px 0 var(--color-text);
}
/* ── Dump post detail header ─────────────────────────────────────── */ /* ── Dump post detail header ─────────────────────────────────────── */
[data-style="brutalist"] .dump-post-header { [data-style="brutalist"] .dump-post-header {
border: 2px solid var(--color-border); border: 2px solid var(--color-border);

View File

@@ -268,6 +268,16 @@
box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 75%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 75%, transparent);
} }
/* A pointed-at headline over a photo lights up like a marquee: neon yellow
on a black plate, ringed and glowing in the accent. */
[data-style="geocities"] .journal-card--image .journal-card-title {
--journal-title-hover-bg: rgb(0 0 0 / 0.82);
--journal-title-hover-color: #ffff00;
--journal-title-hover-shadow: 0 0 0 2px var(--color-accent),
0 0 10px var(--color-accent);
--journal-title-hover-text-shadow: 0 0 6px rgb(255 255 0 / 0.6);
}
/* The divider GIF, minus the GIF: the rainbow the header ends on, run again /* The divider GIF, minus the GIF: the rainbow the header ends on, run again
between sub-blocks with a sparkle sitting in the middle of it. */ between sub-blocks with a sparkle sitting in the middle of it. */
[data-style="geocities"] .journal-break { [data-style="geocities"] .journal-break {

View File

@@ -161,8 +161,14 @@
} }
/* ── On the scrim, links hover in the dark scheme's blue ─────────── */ /* ── On the scrim, links hover in the dark scheme's blue ─────────── */
[data-style="kanagawa"] .journal-card--image .journal-card-title:hover { /* …on a plate of sumiInk, the Wave page itself, in either scheme: the blue
color: var(--kngw-accent-on-photo); is the dark scheme's, so its ground has to be too. Square-ish, like the
selection block an editor draws behind a match. */
[data-style="kanagawa"] .journal-card--image .journal-card-title {
--journal-title-hover-color: var(--kngw-accent-on-photo);
--journal-title-hover-bg: rgb(31 31 40 / 0.86);
--journal-title-hover-radius: var(--radius-xs);
--journal-title-hover-shadow: inset 2px 0 0 var(--kngw-accent-on-photo);
} }
[data-style="kanagawa"] [data-style="kanagawa"]

View File

@@ -314,6 +314,16 @@
color: var(--mel-dust); color: var(--mel-dust);
} }
/* A pointed-at headline over a photo sits on a pillow of roasted coffee —
the dark scheme's ground in either scheme, since the scrim under it is
dark in both — lit in the dark scheme's amber, in warm shadow. */
[data-style="melange"] .journal-card--image .journal-card-title {
--journal-title-hover-bg: rgb(41 37 34 / 0.84);
--journal-title-hover-color: #ebc06d;
--journal-title-hover-radius: var(--radius-sm);
--journal-title-hover-shadow: 0 4px 14px -4px rgb(0 0 0 / 0.5);
}
/* ── A quote is a printed pull-quote: big soft mark, terracotta ──── */ /* ── A quote is a printed pull-quote: big soft mark, terracotta ──── */
[data-style="melange"] .journal-card--quote { [data-style="melange"] .journal-card--quote {
background: var(--color-surface); background: var(--color-surface);

View File

@@ -131,8 +131,18 @@
} }
/* …except over a photo, where the headline is white on a dark scrim: ink /* …except over a photo, where the headline is white on a dark scrim: ink
there goes black-on-black in the light scheme. */ there goes black-on-black in the light scheme. */
[data-style="nyt"] .journal-card--image .journal-card-title {
--journal-title-hover-color: #fff;
/* A caption box set in solid ink — square, flat, no glass. */
--journal-title-hover-bg: rgb(0 0 0 / 0.8);
--journal-title-hover-radius: 0;
}
[data-style="nyt"] .journal-card--image .journal-card-title:hover { [data-style="nyt"] .journal-card--image .journal-card-title:hover {
color: #fff; color: var(--journal-title-hover-color);
}
/* The accent is ink, so over the photo the unread dot goes white with it. */
[data-style="nyt"] .journal-card--image .unread-dot {
background: #fff;
} }
/* Over a playable thumb the card's "open" cue stands down (see App.css). */ /* Over a playable thumb the card's "open" cue stands down (see App.css). */
[data-style="nyt"] [data-style="nyt"]

View File

@@ -99,3 +99,17 @@
--btn-primary-shadow: var(--shadow-1); --btn-primary-shadow: var(--shadow-1);
--btn-primary-shadow-hover: var(--shadow-1); --btn-primary-shadow-hover: var(--shadow-1);
} }
/* A pointed-at journal headline sits on frosted page colour rather than on
black glass: terracotta on cream and jasmine on Prussian Blue are the
pairs this palette was picked for, where the accent on a black plate
would lose the light scheme's terracotta. */
:root:not([data-style]) .journal-card--image .journal-card-title,
[data-style="smooth"] .journal-card--image .journal-card-title {
--journal-title-hover-bg: light-dark(
rgb(255 253 248 / 0.9),
rgb(1 21 42 / 0.78)
);
--journal-title-hover-radius: var(--radius-md);
--journal-title-hover-filter: blur(6px);
}

59
src/utils/authStorage.ts Normal file
View File

@@ -0,0 +1,59 @@
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
// The session is shared by every tab through this one localStorage entry.
export const AUTH_STORAGE_KEY = "authResponse";
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
/** The stored session, or null — discarding one that can no longer be used. */
export function readStoredSession(): AuthResponse | null {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (!stored) return null;
let parsed;
try {
parsed = deserializeAuthResponse(JSON.parse(stored));
} catch {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
if (isTokenExpired(parsed.token)) {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
return parsed;
}
export function storeSession(session: AuthResponse): void {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
/**
* Forget the stored session, but only if it is still the one holding `token`.
* Another tab may already have replaced it — a password change there hands out
* fresh tokens — and a 401 for the old token here must not wipe those.
*/
export function forgetStoredSession(token: string | undefined): void {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (!stored) return;
try {
if (JSON.parse(stored)?.token !== token) return;
} catch {
// Unreadable: nobody can use it.
}
localStorage.removeItem(AUTH_STORAGE_KEY);
}