Compare commits
3 Commits
c7ce31c1bd
...
bfce3186f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfce3186f8 | ||
|
|
2ad8ae73d5 | ||
|
|
3a09d0e354 |
@@ -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 up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
||||
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -40,6 +41,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
||||
{ name: "0015_token_revocation", up: up0015TokenRevocation },
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
17
api/db/migrations/0015_token_revocation.ts
Normal file
17
api/db/migrations/0015_token_revocation.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ CREATE TABLE users (
|
||||
description TEXT,
|
||||
invited_by TEXT REFERENCES users(id),
|
||||
email TEXT NOT NULL,
|
||||
chat_read_at TEXT
|
||||
chat_read_at TEXT,
|
||||
tokens_valid_after TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
import type { Context } from "@oak/oak";
|
||||
import { verifyJWT } from "./jwt.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { verifyJWT, verifyMediaToken } from "./jwt.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.
|
||||
@@ -8,10 +69,10 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
* Distinguishes *missing* credentials from *invalid* ones:
|
||||
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
||||
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
|
||||
* - A real token that fails verification (expired / bad signature) → throws 401,
|
||||
* so a stale client session surfaces as an auth error it can react to, instead
|
||||
* of being silently downgraded to anonymous (which yields a misleading 404 on
|
||||
* private resources).
|
||||
* - A real token that fails verification (expired / bad signature / revoked) →
|
||||
* throws 401, so a stale client session surfaces as an auth error it can react
|
||||
* to, instead of being silently downgraded to anonymous (which yields a
|
||||
* misleading 404 on private resources).
|
||||
*/
|
||||
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||
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();
|
||||
if (!token || token === "undefined" || token === "null") return null;
|
||||
|
||||
const payload = await verifyJWT(token);
|
||||
const payload = await authenticate(token);
|
||||
if (!payload) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -47,10 +48,40 @@ export async function createJWT(
|
||||
): Promise<string> {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
// `iat` is what a password change revokes against (users.tokens_valid_after).
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.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> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||
@@ -86,7 +117,13 @@ export function verifyPassword(
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
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
137
api/lib/public-address.ts
Normal 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
39
api/lib/rate-limit.ts
Normal 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
23
api/lib/self-url.ts
Normal 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);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export async function serveUploadedFile(
|
||||
filePath: string,
|
||||
mime: string,
|
||||
cacheMaxAge = 3600,
|
||||
isPrivate = false,
|
||||
): Promise<boolean> {
|
||||
let data: Uint8Array;
|
||||
try {
|
||||
@@ -75,7 +76,11 @@ export async function serveUploadedFile(
|
||||
}
|
||||
ctx.response.headers.set("Content-Type", mime);
|
||||
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.body = data;
|
||||
return true;
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
type RouterContext,
|
||||
type State,
|
||||
} from "@oak/oak";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type AuthPayload,
|
||||
} from "../model/interfaces.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { can, type Permission } from "../lib/permissions.ts";
|
||||
|
||||
export interface AuthState extends State {
|
||||
@@ -40,23 +39,15 @@ export async function authMiddleware<R extends string>(
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||
}
|
||||
|
||||
let user;
|
||||
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 };
|
||||
ctx.state.user = payload;
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -220,11 +220,14 @@ export interface AuthPayload {
|
||||
username: string;
|
||||
role: Role;
|
||||
exp: number;
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
// Purpose-scoped tokens (password reset, media) are never session tokens.
|
||||
!("purpose" in obj) &&
|
||||
"userId" in obj && typeof obj.userId === "string" &&
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
"role" in obj && isRole(obj.role) &&
|
||||
|
||||
@@ -37,6 +37,10 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
|
||||
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.
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
@@ -193,7 +197,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dumpId,
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : 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 };
|
||||
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 };
|
||||
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 };
|
||||
ctx.response.body = responseBody;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump } from "../services/dump-service.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" });
|
||||
|
||||
@@ -14,15 +14,31 @@ const router = new Router({ prefix: "/api/files" });
|
||||
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
||||
* full UTF-8 name for modern browsers.
|
||||
*/
|
||||
function contentDisposition(name: string): string {
|
||||
function contentDisposition(
|
||||
type: "inline" | "attachment",
|
||||
name: string,
|
||||
): string {
|
||||
const asciiFallback = name
|
||||
.replace(/["\\]/g, "_")
|
||||
.replace(/[^\x20-\x7e]/g, "_");
|
||||
return `inline; filename="${asciiFallback}"; filename*=UTF-8''${
|
||||
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
|
||||
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) => {
|
||||
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
|
||||
// access to private dumps the requester owns.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const payload = token ? await verifyJWT(token) : null;
|
||||
const dump = getDump(dumpId, payload?.userId);
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(
|
||||
@@ -56,11 +72,27 @@ router.get("/:dumpId", async (ctx) => {
|
||||
}
|
||||
|
||||
const total = data.byteLength;
|
||||
const inline = isInlineSafe(dump.fileMime);
|
||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||
ctx.response.headers.set(
|
||||
"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");
|
||||
|
||||
const rangeHeader = ctx.request.headers.get("Range");
|
||||
|
||||
@@ -5,10 +5,24 @@ import {
|
||||
tryFetchRichContent,
|
||||
} from "../services/rich-content-service.ts";
|
||||
import { APIErrorCode } from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
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") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
@@ -42,8 +56,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, 8000);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
// Raster formats only. The bytes go out under this site's origin, so an
|
||||
// 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;
|
||||
return;
|
||||
}
|
||||
@@ -51,6 +70,7 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
||||
if (contentLength > MAX_SIZE) {
|
||||
await res.body?.cancel();
|
||||
ctx.response.status = 400;
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +82,11 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
}
|
||||
|
||||
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.body = bytes;
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
|
||||
import { serveUploadedFile } from "../lib/upload.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" });
|
||||
|
||||
@@ -28,6 +28,14 @@ async function generateThumbnail(
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
const cmd = new Deno.Command("ffmpeg", {
|
||||
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",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
@@ -58,8 +66,14 @@ router.get("/:dumpId", async (ctx) => {
|
||||
// Token via query param (media elements can't send headers) — authorizes the
|
||||
// owner's private dumps and gates the custom-thumbnail branch below too.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const payload = token ? await verifyJWT(token) : null;
|
||||
const dump = getDump(dumpId, payload?.userId);
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
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);
|
||||
if (customThumb) {
|
||||
@@ -67,6 +81,8 @@ router.get("/:dumpId", async (ctx) => {
|
||||
ctx,
|
||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||
customThumb.mime,
|
||||
3600,
|
||||
dump.isPrivate,
|
||||
);
|
||||
if (served) return;
|
||||
}
|
||||
@@ -85,10 +101,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
try {
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||
ctx.response.body = data;
|
||||
return;
|
||||
} catch {
|
||||
@@ -116,10 +129,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||
ctx.response.body = data;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
isRole,
|
||||
isUpdateUserRequest,
|
||||
type PaginatedData,
|
||||
type User,
|
||||
validateRegisterUserRequest,
|
||||
} 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 {
|
||||
type AuthContext,
|
||||
authMiddleware,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
searchUsers,
|
||||
updateUser,
|
||||
} from "../services/user-service.ts";
|
||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
@@ -53,6 +55,22 @@ import {
|
||||
// Users router
|
||||
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)
|
||||
router.post("/register", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
@@ -62,16 +80,16 @@ router.post("/register", async (ctx) => {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
||||
}
|
||||
|
||||
// Validate invite — throws 404/409 if bad
|
||||
const inviterId = validateInvite(body.inviteToken);
|
||||
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||
// account can't be created, so one invite can't open two accounts.
|
||||
const inviterId = claimInvite(body.inviteToken);
|
||||
|
||||
const user = await createUser(body, inviterId);
|
||||
|
||||
// Mark invite as used only after the user row is committed
|
||||
let user;
|
||||
try {
|
||||
redeemInvite(body.inviteToken);
|
||||
user = await createUser(body, inviterId);
|
||||
} catch (err) {
|
||||
console.error("[register] redeemInvite failed (user created):", err);
|
||||
releaseInvite(body.inviteToken);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
@@ -88,63 +106,62 @@ router.post("/register", async (ctx) => {
|
||||
}).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;
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { token: authToken, user: publicUser },
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
};
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json();
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
const user = getUserByUsername(body.username);
|
||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||
|
||||
if (!valid) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
}
|
||||
|
||||
const token = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: publicUser,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// Per account, so guessing one user's password is slow however many
|
||||
// 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) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
};
|
||||
});
|
||||
|
||||
// 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)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
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 });
|
||||
ctx.response.body = { success: true };
|
||||
// Changing the password revokes every token issued before it, this
|
||||
// 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.)
|
||||
@@ -255,10 +284,14 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
// Self-service profile updates must never set the role. Role assignment is
|
||||
// done out-of-band (DB/seed); stripping it here prevents self-promotion.
|
||||
const { role: _role, ...safeBody } = body;
|
||||
const updated = await updateUser(ctx.state.user.userId, safeBody);
|
||||
// Only the profile fields. The role is assigned by an admin, the password
|
||||
// changes through /me/change-password (which asks for the current one), and
|
||||
// the username is fixed — a token alone must not be able to change any of
|
||||
// them.
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
});
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
|
||||
215
api/routes/ws.ts
215
api/routes/ws.ts
@@ -1,6 +1,6 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
import {
|
||||
broadcastChatMessage,
|
||||
broadcastChatMessageDeleted,
|
||||
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
|
||||
import {
|
||||
APIException,
|
||||
type ClientToServerMessage,
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
const router = new Router();
|
||||
@@ -64,19 +65,24 @@ router.get("/ws", async (ctx) => {
|
||||
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 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();
|
||||
|
||||
// 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 = {
|
||||
socket,
|
||||
userId: authPayload?.userId,
|
||||
username: authPayload?.username,
|
||||
userId: user?.id,
|
||||
username: user?.username,
|
||||
role: user?.role,
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
@@ -87,16 +93,10 @@ router.get("/ws", async (ctx) => {
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||
const myCommentLikes = authPayload
|
||||
? getUserCommentLikes(authPayload.userId)
|
||||
: [];
|
||||
const unreadNotificationCount = authPayload
|
||||
? getUnreadCount(authPayload.userId)
|
||||
: 0;
|
||||
const chatRead = authPayload
|
||||
? getChatReadState(authPayload.userId)
|
||||
: undefined;
|
||||
const myVotes = user ? getUserVotes(user.id) : [];
|
||||
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
|
||||
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
|
||||
const chatRead = user ? getChatReadState(user.id) : undefined;
|
||||
socket.send(JSON.stringify({
|
||||
type: "welcome",
|
||||
users: getOnlineUsers(),
|
||||
@@ -112,56 +112,12 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
|
||||
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 {
|
||||
msg = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
socket.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "pong":
|
||||
handleClientPong(client);
|
||||
break;
|
||||
case "vote_cast":
|
||||
handleVote(client, msg.dumpId, "cast");
|
||||
break;
|
||||
case "vote_remove":
|
||||
handleVote(client, msg.dumpId, "remove");
|
||||
break;
|
||||
case "comment_like_cast":
|
||||
handleCommentLike(client, msg.commentId, "cast");
|
||||
break;
|
||||
case "comment_like_remove":
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
) {
|
||||
setClientTyping(client, msg.typing === true);
|
||||
}
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open === true;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
break;
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
case "chat_read":
|
||||
handleChatRead(client, msg.at);
|
||||
break;
|
||||
handleMessage(client, event.data);
|
||||
} catch (err) {
|
||||
console.error("[ws] message handler failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,70 +129,150 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
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) {
|
||||
case "ping":
|
||||
send(socket, { type: "pong" });
|
||||
break;
|
||||
case "pong":
|
||||
handleClientPong(client);
|
||||
break;
|
||||
case "vote_cast":
|
||||
handleVote(client, msg.dumpId, "cast");
|
||||
break;
|
||||
case "vote_remove":
|
||||
handleVote(client, msg.dumpId, "remove");
|
||||
break;
|
||||
case "comment_like_cast":
|
||||
handleCommentLike(client, msg.commentId, "cast");
|
||||
break;
|
||||
case "comment_like_remove":
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
) {
|
||||
setClientTyping(client, msg.typing === true);
|
||||
}
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open === true;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
break;
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
case "chat_read":
|
||||
handleChatRead(client, msg.at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||
function send(socket: WebSocket, data: Record<string, unknown>): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
dumpId: string,
|
||||
dumpId: unknown,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
send(socket, { type: "error", message: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (typeof dumpId !== "string") return;
|
||||
|
||||
try {
|
||||
const newCount = action === "cast"
|
||||
const { voteCount, isPrivate } = action === "cast"
|
||||
? castVote(dumpId, client.userId)
|
||||
: removeVote(dumpId, client.userId);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
send(socket, {
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action,
|
||||
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) {
|
||||
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(
|
||||
client: WsClient,
|
||||
commentId: string,
|
||||
commentId: unknown,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
send(socket, { type: "error", message: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (typeof commentId !== "string") return;
|
||||
|
||||
try {
|
||||
const newCount = action === "cast"
|
||||
const { likeCount, isPrivate } = action === "cast"
|
||||
? castCommentLike(commentId, client.userId)
|
||||
: removeCommentLike(commentId, client.userId);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
send(socket, {
|
||||
type: "comment_like_ack",
|
||||
commentId,
|
||||
action,
|
||||
likeCount: newCount,
|
||||
}));
|
||||
likeCount,
|
||||
});
|
||||
|
||||
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
|
||||
broadcastCommentLikeUpdate(
|
||||
commentId,
|
||||
likeCount,
|
||||
client.userId,
|
||||
action,
|
||||
isPrivate ? client.userId : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
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,
|
||||
nonce?: string,
|
||||
): void {
|
||||
if (client.socket.readyState !== WebSocket.OPEN) return;
|
||||
client.socket.send(
|
||||
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
|
||||
);
|
||||
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
||||
}
|
||||
|
||||
// Validates the common preconditions of a chat write: signed in and under the
|
||||
|
||||
@@ -252,8 +252,11 @@ export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const newest = db.prepare(
|
||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||
).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 target = newest.newest && iso > newest.newest ? newest.newest : iso;
|
||||
const target = iso > newest.newest ? newest.newest : iso;
|
||||
db.prepare(
|
||||
`UPDATE users SET chat_read_at = ?
|
||||
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
|
||||
|
||||
@@ -2,7 +2,29 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.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;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
@@ -34,10 +56,20 @@ export function castCommentLike(commentId: string, userId: string): number {
|
||||
try {
|
||||
notifyCommentOwnerLike(userId, commentId);
|
||||
} 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 {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
@@ -54,7 +86,7 @@ export function removeCommentLike(commentId: string, userId: string): number {
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.like_count ?? 0;
|
||||
return { likeCount: row?.like_count ?? 0, isPrivate };
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
DUMP_MAX_FILE_SIZE_BYTES,
|
||||
DUMPS_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
VALIDATION,
|
||||
} from "../config.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
@@ -46,6 +47,25 @@ function isAllowedMime(mime: string): boolean {
|
||||
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 {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -127,6 +147,7 @@ export async function createFileDump(
|
||||
title?: string,
|
||||
categoryIds: string[] = [],
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
@@ -391,6 +412,8 @@ export async function updateDump(
|
||||
}
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
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) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
@@ -468,6 +491,8 @@ export async function updateDump(
|
||||
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
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) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
@@ -489,6 +514,7 @@ export async function replaceFileDump(
|
||||
title?: string,
|
||||
categoryIds?: string[],
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
|
||||
@@ -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 {
|
||||
db.prepare(
|
||||
`UPDATE invites SET used_at = ? WHERE token = ?;`,
|
||||
export function claimInvite(token: string): string {
|
||||
const inviterId = validateInvite(token);
|
||||
const result = db.prepare(
|
||||
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||
).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);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
VALIDATION,
|
||||
} from "../config.ts";
|
||||
import { marked } from "marked";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
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
|
||||
* 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> {
|
||||
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
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(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
@@ -101,9 +112,11 @@ export async function resetPassword(
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
|
||||
).run(new Date().toISOString(), token);
|
||||
try {
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
} catch (err) {
|
||||
db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`)
|
||||
.run(token);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function updatePlaylist(
|
||||
);
|
||||
linkAttachments(newDescription, playlist.id);
|
||||
}
|
||||
broadcastPlaylistUpdated(updated);
|
||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export function setPlaylistImage(
|
||||
playlist.id,
|
||||
);
|
||||
const updated = getPlaylistById(playlist.id);
|
||||
broadcastPlaylistUpdated(updated);
|
||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,17 @@ export function addDumpToPlaylist(
|
||||
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(
|
||||
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
||||
).get(playlist.id) as { min_pos: number | null } | undefined;
|
||||
@@ -283,18 +294,14 @@ export function addDumpToPlaylist(
|
||||
const dumpIds = getCurrentDumpIds(playlist.id);
|
||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||
|
||||
if (playlist.isPublic) {
|
||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||
// Followers can't open a private dump, even the owner's own.
|
||||
if (playlist.isPublic && !dump.is_private) {
|
||||
notifyPlaylistFollowersNewDump(
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
dumpId,
|
||||
) as { title: string } | undefined;
|
||||
if (dumpRow) {
|
||||
notifyPlaylistFollowersNewDump(
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
dumpId,
|
||||
dumpRow.title,
|
||||
);
|
||||
}
|
||||
dump.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDump } from "../dump-service.ts";
|
||||
import { getUserByUsername } from "../user-service.ts";
|
||||
import { getPlaylistById } from "../playlist-service.ts";
|
||||
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||
import { isSelfUrl } from "../../lib/self-url.ts";
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
const USER_RE = /^\/users\/([^/]+)$/;
|
||||
@@ -22,10 +23,7 @@ export const selfProvider: RichContentProvider = {
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
return DUMP_RE.test(pathname) ||
|
||||
USER_RE.test(pathname) ||
|
||||
PLAYLIST_RE.test(pathname);
|
||||
return isSelfUrl(new URL(url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { bandcampProvider } from "./providers/bandcamp.ts";
|
||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||
import { selfProvider } from "./providers/self.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 {
|
||||
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",
|
||||
};
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
|
||||
* 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(
|
||||
url: string,
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response | null> {
|
||||
const tmpPath = await Deno.makeTempFile();
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const pinned = address.includes(":") ? `[${address}]` : address;
|
||||
try {
|
||||
const { code, stdout } = await new Deno.Command("curl", {
|
||||
args: [
|
||||
"--silent",
|
||||
"--insecure",
|
||||
"--location",
|
||||
"--proto",
|
||||
"=http,https",
|
||||
"--resolve",
|
||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||
"--max-time",
|
||||
String(Math.ceil(timeoutMs / 1000)),
|
||||
"--user-agent",
|
||||
@@ -62,8 +79,8 @@ async function fetchViaCurl(
|
||||
"--output",
|
||||
tmpPath,
|
||||
"--write-out",
|
||||
"%{content_type}",
|
||||
url,
|
||||
"%{http_code}\n%{redirect_url}\n%{content_type}",
|
||||
url.href,
|
||||
],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
@@ -71,8 +88,18 @@ async function fetchViaCurl(
|
||||
|
||||
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);
|
||||
if (!contentType.startsWith("text/")) {
|
||||
return new Response(bytes, { status: statusCode, headers });
|
||||
}
|
||||
// Decode using the charset declared in the Content-Type header so that
|
||||
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
||||
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||
@@ -82,7 +109,8 @@ async function fetchViaCurl(
|
||||
} catch {
|
||||
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 {
|
||||
return null;
|
||||
} finally {
|
||||
@@ -90,25 +118,20 @@ async function fetchViaCurl(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
/** One request, no redirects followed. */
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
async function attempt(): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await attempt();
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
||||
throw err;
|
||||
@@ -118,10 +141,37 @@ export async function fetchWithTimeout(
|
||||
// or an unsupported signature algorithm) and offers no way to override
|
||||
// verification programmatically. Fall back to a `curl --insecure`
|
||||
// subprocess, which uses its own TLS stack.
|
||||
const curlRes = await fetchViaCurl(url, timeoutMs);
|
||||
const curlRes = await fetchViaCurl(url, address, timeoutMs);
|
||||
if (curlRes) return curlRes;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
// Allow private hosts for self-referential gerbeur URLs — they are
|
||||
// resolved directly from the DB by selfProvider, no outbound HTTP needed.
|
||||
if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// This site's own pages are fine even on a private host (e.g. localhost in
|
||||
// development): selfProvider reads them from the DB, no HTTP involved.
|
||||
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
|
||||
return !isPrivateHostname(u) || isSelfUrl(u);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type User,
|
||||
} from "../model/interfaces.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 { linkAttachments } from "./attachment-service.ts";
|
||||
@@ -36,20 +36,35 @@ export async function createUser(
|
||||
);
|
||||
}
|
||||
|
||||
assertEmailAvailable(request.email);
|
||||
|
||||
const passwordHash = await hashPassword(request.password);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
passwordHash,
|
||||
"user",
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
request.email,
|
||||
);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
passwordHash,
|
||||
"user",
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
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 {
|
||||
id: userId,
|
||||
@@ -85,6 +100,23 @@ export function getUserByUsername(username: string): User {
|
||||
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 {
|
||||
const userRow = db.prepare(
|
||||
`${USER_SELECT} WHERE u.email = ?`,
|
||||
@@ -141,6 +173,9 @@ export async function updateUser(
|
||||
const user = getUserById(userId);
|
||||
|
||||
const { password, description, email, ...requestFields } = request;
|
||||
if (email !== undefined && email !== user.email) {
|
||||
assertEmailAvailable(email, userId);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedUser: User = {
|
||||
@@ -170,6 +205,15 @@ export async function updateUser(
|
||||
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) {
|
||||
linkAttachments(updatedUser.description, userId);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,26 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.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;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
@@ -34,10 +53,16 @@ export function castVote(dumpId: string, userId: string): number {
|
||||
try {
|
||||
notifyDumpOwnerUpvote(userId, dumpId);
|
||||
} 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 {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
@@ -50,11 +75,11 @@ export function removeVote(dumpId: string, userId: string): number {
|
||||
db.prepare(
|
||||
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
||||
).run(dumpId);
|
||||
const row = db.prepare(
|
||||
const count = db.prepare(
|
||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { vote_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.vote_count ?? 0;
|
||||
return { voteCount: count?.vote_count ?? 0, isPrivate };
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
|
||||
@@ -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 {
|
||||
const users = getOnlineUsers();
|
||||
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(
|
||||
dumpId: string,
|
||||
voteCount: number,
|
||||
voterId: string,
|
||||
action: "cast" | "remove",
|
||||
onlyUserId?: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||
send(client.socket, {
|
||||
type: "votes_update",
|
||||
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(
|
||||
commentId: string,
|
||||
likeCount: number,
|
||||
likerId: string,
|
||||
action: "cast" | "remove",
|
||||
onlyUserId?: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||
send(client.socket, {
|
||||
type: "comment_likes_update",
|
||||
commentId,
|
||||
@@ -157,12 +175,17 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
||||
// Broadcast to ALL clients so non-owners can react to visibility changes
|
||||
// (e.g. remove a now-private playlist from their feed).
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "playlist_updated", playlist });
|
||||
}
|
||||
// `wasPublic`: the visibility before this change. A playlist that just went
|
||||
// private still goes to everyone, so non-owners can drop it from their views;
|
||||
// one that was and stays private goes to its owner alone.
|
||||
export function broadcastPlaylistUpdated(
|
||||
playlist: Playlist,
|
||||
wasPublic: boolean,
|
||||
): void {
|
||||
sendToPlaylistAudience(
|
||||
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
|
||||
{ type: "playlist_updated", playlist },
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastPlaylistDeleted(
|
||||
@@ -305,44 +328,7 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket,
|
||||
// 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 { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
|
||||
31
src/App.css
31
src/App.css
@@ -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-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;
|
||||
transition:
|
||||
color var(--dur-base),
|
||||
background-color var(--dur-base),
|
||||
box-shadow var(--dur-base);
|
||||
}
|
||||
.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 {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
|
||||
@@ -37,27 +37,22 @@ export function AddToPlaylistModal(
|
||||
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
const { playlist, hasDump } = membership;
|
||||
if (hasDump) {
|
||||
await authFetch(
|
||||
// The box only changes once the server agrees; a refusal or a dropped
|
||||
// connection leaves it showing the truth.
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
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
|
||||
)
|
||||
{ method: hasDump ? "DELETE" : "POST" },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setMemberships((prev) =>
|
||||
prev.map((m) =>
|
||||
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Values {
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const { authFetch, updateSession } = useAuth();
|
||||
const [done, setDone] = useState(false);
|
||||
const form = useApiForm<Values>({
|
||||
mode: "onChange",
|
||||
@@ -33,13 +33,16 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
});
|
||||
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
);
|
||||
updateSession(tokens);
|
||||
setDone(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Tooltip } from "./Tooltip.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
@@ -30,13 +31,6 @@ import {
|
||||
useApiForm,
|
||||
} 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 {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
@@ -208,13 +202,13 @@ function CommentNode({
|
||||
const editEditorRef = useRef<TextEditorHandle>(null);
|
||||
|
||||
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
const children = tree.get(comment.id) ?? [];
|
||||
|
||||
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",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(
|
||||
{ body, parentId: comment.id } satisfies CreateCommentRequest,
|
||||
),
|
||||
@@ -225,9 +219,8 @@ function CommentNode({
|
||||
|
||||
async function handleDelete() {
|
||||
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",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
onCommentDeleted(comment.id);
|
||||
@@ -235,9 +228,8 @@ function CommentNode({
|
||||
}
|
||||
|
||||
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",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
||||
});
|
||||
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
@@ -452,11 +444,11 @@ export function CommentThread({
|
||||
}: CommentThreadProps) {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
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",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import { useScrollLock } from "../hooks/useScrollLock.ts";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
|
||||
@@ -19,8 +19,8 @@ interface DumpCardProps {
|
||||
voteCount: number;
|
||||
voted: boolean;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
className?: string;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function DumpCard(
|
||||
DumpCardProps,
|
||||
) {
|
||||
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||
const { token } = useAuth();
|
||||
const { mediaToken } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const dumpCategories = (dump.categoryIds ?? [])
|
||||
.map((id) => byId.get(id))
|
||||
@@ -59,7 +59,7 @@ export function DumpCard(
|
||||
compact
|
||||
dumpHref={dumpUrl(dump)}
|
||||
thumbnailOverrideUrl={dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -386,12 +386,19 @@ export function DumpCreateModal(
|
||||
// same timing for the user, just not in the same tick.
|
||||
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(() => {
|
||||
const encoded = encodeURIComponent(canonicalUrl);
|
||||
|
||||
fetch(`${API_URL}/api/preview?url=${encoded}`)
|
||||
authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal })
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
if (signal.aborted) return;
|
||||
const data = body.success
|
||||
? body.data as UrlPreviewResponse
|
||||
: undefined;
|
||||
@@ -402,14 +409,16 @@ export function DumpCreateModal(
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (signal.aborted) return;
|
||||
setUrlFetchState({ status: "done", reached: false, richContent: null });
|
||||
});
|
||||
|
||||
// Signed in, so that a private dump of the poster's own counts as a
|
||||
// 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((body) => {
|
||||
if (signal.aborted) return;
|
||||
setDuplicateState({
|
||||
url: canonicalUrl,
|
||||
matches: body.success
|
||||
@@ -417,13 +426,16 @@ export function DumpCreateModal(
|
||||
: [],
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
setDuplicateState({ url: canonicalUrl, matches: [] })
|
||||
);
|
||||
.catch(() => {
|
||||
if (!signal.aborted) {
|
||||
setDuplicateState({ url: canonicalUrl, matches: [] });
|
||||
}
|
||||
});
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
controller.abort();
|
||||
};
|
||||
}, [canonicalUrl, authFetch]);
|
||||
|
||||
@@ -613,27 +625,22 @@ export function DumpCreateModal(
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
if (!createdDump) return;
|
||||
const { playlist, hasDump } = membership;
|
||||
if (hasDump) {
|
||||
await authFetch(
|
||||
// The box only changes once the server agrees; a refusal or a dropped
|
||||
// connection leaves it showing the truth.
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
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
|
||||
)
|
||||
{ method: hasDump ? "DELETE" : "POST" },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setMemberships((prev) =>
|
||||
prev.map((m) =>
|
||||
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const canProceed = !!file || !!canonicalUrl;
|
||||
|
||||
@@ -52,7 +52,7 @@ function filePlayerItem(
|
||||
function AudioFilePreview(
|
||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||
) {
|
||||
const { token } = useAuth();
|
||||
const { mediaToken: token } = useAuth();
|
||||
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
||||
useContext(PlayerContext);
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
@@ -174,7 +174,7 @@ export default function FilePreview(
|
||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||
) {
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
const { token } = useAuth();
|
||||
const { mediaToken: token } = useAuth();
|
||||
const fileUrl = dumpFileUrl(dump, token);
|
||||
const mime = dump.fileMime ?? "";
|
||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||
|
||||
@@ -24,8 +24,8 @@ interface JournalCardProps {
|
||||
voteCount: number;
|
||||
voted: boolean;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function JournalCard(
|
||||
JournalCardProps,
|
||||
) {
|
||||
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||
const { token } = useAuth();
|
||||
const { mediaToken } = useAuth();
|
||||
const { playRichContent } = usePlayRichContent();
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
@@ -49,12 +49,12 @@ export function JournalCard(
|
||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||
// still here too, rather than degrading to a text card with a 🎬.
|
||||
const thumbnailUrl = dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: dump.kind === "file"
|
||||
? (dump.fileMime?.startsWith("image/")
|
||||
? dumpFileUrl(dump, token)
|
||||
? dumpFileUrl(dump, mediaToken)
|
||||
: dump.fileMime?.startsWith("video/")
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: null)
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ interface LikeButtonProps {
|
||||
count: number;
|
||||
liked: boolean;
|
||||
disabled?: boolean;
|
||||
onLike: (commentId: string) => void;
|
||||
onUnlike: (commentId: string) => void;
|
||||
onLike: (commentId: string, shownCount?: number) => void;
|
||||
onUnlike: (commentId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
export function LikeButton(
|
||||
@@ -25,7 +25,8 @@ export function LikeButton(
|
||||
className={`comment-action-btn${
|
||||
liked ? " comment-action-btn--liked" : ""
|
||||
}`}
|
||||
onClick={() => liked ? onUnlike(commentId) : onLike(commentId)}
|
||||
onClick={() =>
|
||||
liked ? onUnlike(commentId, count) : onLike(commentId, count)}
|
||||
disabled={disabled}
|
||||
aria-label={liked ? t`Remove like` : t`Like`}
|
||||
title={disabled ? t`Log in to like` : undefined}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -9,13 +8,57 @@ interface MarkdownProps {
|
||||
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
|
||||
function preprocessMentions(text: string): string {
|
||||
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
||||
interface MdNode {
|
||||
type: string;
|
||||
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"];
|
||||
|
||||
// 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(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
const processed = useMemo(() => preprocessMentions(children), [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`md${className ? ` ${className}` : ""}${
|
||||
@@ -57,7 +98,7 @@ export function Markdown(
|
||||
? INLINE_MARKDOWN_COMPONENTS
|
||||
: MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{processed}
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import { useScrollLock } from "../hooks/useScrollLock.ts";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
|
||||
@@ -8,8 +8,8 @@ interface VoteButtonProps {
|
||||
count: number;
|
||||
voted: boolean;
|
||||
disabled?: boolean;
|
||||
onCast: (dumpId: string) => void;
|
||||
onRemove: (dumpId: string) => void;
|
||||
onCast: (dumpId: string, shownCount?: number) => void;
|
||||
onRemove: (dumpId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
export function VoteButton(
|
||||
@@ -23,7 +23,7 @@ export function VoteButton(
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||
onClick={() => voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
|
||||
disabled={disabled}
|
||||
aria-label={voted ? t`Remove vote` : t`Upvote`}
|
||||
title={disabled ? t`Log in to vote` : undefined}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
import { createContext, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import { type AuthResponse } from "../model.ts";
|
||||
|
||||
export interface AuthContextValue {
|
||||
authResponse: AuthResponse | null;
|
||||
setAuthResponse: (authResponse: AuthResponse | null) => void;
|
||||
setAuthResponse: Dispatch<SetStateAction<AuthResponse | null>>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
|
||||
@@ -2,48 +2,31 @@ import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
||||
|
||||
import {
|
||||
type AuthResponse,
|
||||
deserializeAuthResponse,
|
||||
deserializePublicUser,
|
||||
} from "../model.ts";
|
||||
import { type AuthResponse, deserializePublicUser } from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import {
|
||||
AUTH_STORAGE_KEY,
|
||||
forgetStoredSession,
|
||||
readStoredSession,
|
||||
storeSession,
|
||||
} from "../utils/authStorage.ts";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
|
||||
const stored = localStorage.getItem("authResponse");
|
||||
if (!stored) return null;
|
||||
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(
|
||||
readStoredSession,
|
||||
);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = deserializeAuthResponse(JSON.parse(stored));
|
||||
} catch {
|
||||
localStorage.removeItem("authResponse");
|
||||
return null;
|
||||
}
|
||||
if (isTokenExpired(parsed.token)) {
|
||||
localStorage.removeItem("authResponse");
|
||||
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("authResponse");
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
});
|
||||
// Every tab shares the stored session, so follow it: a login, logout or
|
||||
// password change in another tab applies here too, rather than this tab
|
||||
// carrying on with a token the server has since revoked.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return;
|
||||
setAuthResponse(readStoredSession());
|
||||
};
|
||||
globalThis.addEventListener("storage", onStorage);
|
||||
return () => globalThis.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
@@ -62,8 +45,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("authResponse");
|
||||
setAuthResponse(null);
|
||||
// Only this token is dead; the session may have moved on to a
|
||||
// fresh one (another tab's password change) while we asked.
|
||||
forgetStoredSession(token);
|
||||
setAuthResponse((prev) => prev?.token === token ? null : prev);
|
||||
return;
|
||||
}
|
||||
// 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) => {
|
||||
if (!prev || prev.user.role === freshUser.role) return prev;
|
||||
const updated = { ...prev, user: freshUser };
|
||||
localStorage.setItem("authResponse", JSON.stringify(updated));
|
||||
storeSession(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -99,6 +84,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [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 };
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
playerItemKey,
|
||||
} from "./PlayerContext.ts";
|
||||
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
const STORAGE_KEY = "player";
|
||||
|
||||
@@ -20,6 +21,11 @@ interface StoredSession {
|
||||
queue: PlayerItem[];
|
||||
index: 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 {
|
||||
@@ -28,13 +34,25 @@ function isPlayerItem(value: unknown): value is PlayerItem {
|
||||
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 {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
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;
|
||||
if (Array.isArray(rawQueue)) {
|
||||
const queue = rawQueue.filter(isPlayerItem);
|
||||
@@ -60,8 +78,10 @@ function readSession(): { queue: PlayerItem[]; index: number; time: number } | n
|
||||
const RERESOLVE_COOLDOWN_MS = 30_000;
|
||||
|
||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const owner = user?.id ?? null;
|
||||
// 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 [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
|
||||
@@ -323,7 +343,13 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(
|
||||
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
|
||||
{
|
||||
v: 2,
|
||||
queue,
|
||||
index: queueIndex,
|
||||
time: currentTime,
|
||||
owner,
|
||||
} satisfies StoredSession,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -331,12 +357,23 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
// currentTime intentionally omitted from deps — see pagehide writer below.
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
sessionRef.current = queue.length > 0
|
||||
? { v: 2, queue, index: queueIndex, time: currentTime }
|
||||
? { v: 2, queue, index: queueIndex, time: currentTime, owner }
|
||||
: null;
|
||||
});
|
||||
|
||||
|
||||
@@ -101,10 +101,11 @@ export interface WSContextValue {
|
||||
typingUsers: OnlineUser[];
|
||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||
connectionEpoch: number;
|
||||
castVote: (dumpId: string) => void;
|
||||
removeVote: (dumpId: string) => void;
|
||||
castCommentLike: (commentId: string) => void;
|
||||
removeCommentLike: (commentId: string) => void;
|
||||
/** `shownCount`: the count on screen, used until a live one arrives. */
|
||||
castVote: (dumpId: string, shownCount?: number) => void;
|
||||
removeVote: (dumpId: string, shownCount?: number) => void;
|
||||
castCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
removeCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||
|
||||
@@ -450,9 +450,20 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
}
|
||||
|
||||
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);
|
||||
setDeletedDumpIds((prev) => {
|
||||
if (!prev.has(dump.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(dump.id);
|
||||
return next;
|
||||
});
|
||||
setRecentDumps((prev) =>
|
||||
[dump, ...prev].slice(0, MAX_RECENT_DUMPS)
|
||||
[dump, ...prev.filter((d) => d.id !== dump.id)].slice(
|
||||
0,
|
||||
MAX_RECENT_DUMPS,
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -460,19 +471,13 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
case "dump_updated": {
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setLastDumpEvent(dump);
|
||||
// Un-delete if this dump was previously removed from the feed
|
||||
// (e.g. it was made private, and is now public again).
|
||||
setDeletedDumpIds((prev) => {
|
||||
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).
|
||||
// An edit is not an arrival: refresh a live entry in place, but
|
||||
// don't pull an old dump to the top of the feeds (going public
|
||||
// comes as a `dump_created`).
|
||||
setRecentDumps((prev) =>
|
||||
prev.some((d) => d.id === dump.id)
|
||||
? prev
|
||||
: [dump, ...prev].slice(0, MAX_RECENT_DUMPS)
|
||||
? prev.map((d) => d.id === dump.id ? dump : d)
|
||||
: prev
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -745,9 +750,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
token,
|
||||
]);
|
||||
|
||||
const castVote = useCallback((dumpId: string) => {
|
||||
// Optimistic update
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? 0;
|
||||
const castVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
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
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const removeVote = useCallback((dumpId: string) => {
|
||||
// Optimistic update
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? 0;
|
||||
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
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
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const castCommentLike = useCallback((commentId: string) => {
|
||||
// Optimistic update
|
||||
const prevCount = likeCountsRef.current[commentId] ?? 0;
|
||||
const castCommentLike = useCallback((commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
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
|
||||
}, [schedulePendingLike]);
|
||||
|
||||
const removeCommentLike = useCallback((commentId: string) => {
|
||||
// Optimistic update
|
||||
const prevCount = likeCountsRef.current[commentId] ?? 0;
|
||||
const removeCommentLike = useCallback((commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return; // not liked
|
||||
|
||||
@@ -1038,11 +1047,16 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
|
||||
const reconcileChat = useCallback(
|
||||
(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) =>
|
||||
prev.filter((m) =>
|
||||
ids.has(m.id) || m.createdAt < from || m.createdAt > to
|
||||
)
|
||||
prev
|
||||
.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.
|
||||
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
||||
|
||||
@@ -3,27 +3,37 @@ import { useCallback, useContext } from "react";
|
||||
import { AuthContext } from "../contexts/AuthContext.ts";
|
||||
|
||||
import { type AuthResponse } from "../model.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import {
|
||||
forgetStoredSession,
|
||||
isTokenExpired,
|
||||
storeSession,
|
||||
} from "../utils/authStorage.ts";
|
||||
|
||||
export const useAuth = () => {
|
||||
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
||||
|
||||
const login = useCallback((response: AuthResponse) => {
|
||||
setAuthResponse(response);
|
||||
localStorage.setItem("authResponse", JSON.stringify(response));
|
||||
storeSession(response);
|
||||
}, [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(() => {
|
||||
setAuthResponse(null);
|
||||
localStorage.removeItem("authResponse");
|
||||
setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
|
||||
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]);
|
||||
|
||||
const authFetch = useCallback(async (
|
||||
@@ -116,20 +126,43 @@ export const useAuth = () => {
|
||||
return {
|
||||
user: authResponse?.user ?? 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,
|
||||
logout,
|
||||
updateSession,
|
||||
authFetch,
|
||||
authUpload,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRequiredAuth = () => {
|
||||
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
||||
const {
|
||||
user,
|
||||
token,
|
||||
mediaToken,
|
||||
login,
|
||||
logout,
|
||||
updateSession,
|
||||
authFetch,
|
||||
authUpload,
|
||||
} = useAuth();
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
"Invariant: useRequiredAuth called outside a protected route",
|
||||
);
|
||||
}
|
||||
return { user, token, login, logout, authFetch, authUpload };
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
mediaToken,
|
||||
login,
|
||||
logout,
|
||||
updateSession,
|
||||
authFetch,
|
||||
authUpload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export function useScrollLock() {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (locks++ === 0) {
|
||||
if (window.innerWidth > root.clientWidth) {
|
||||
if (globalThis.innerWidth > root.clientWidth) {
|
||||
root.style.scrollbarGutter = "stable";
|
||||
}
|
||||
root.style.overflowY = "hidden";
|
||||
|
||||
@@ -208,6 +208,8 @@ export function deserializeInviteTreeEntry(
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
/** Read-only token for media URLs (`?token=`); absent on older sessions. */
|
||||
mediaToken?: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export function Dump() {
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState<string | null>(null);
|
||||
|
||||
const { user, token, authFetch } = useAuth();
|
||||
const { user, token, mediaToken, authFetch } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const {
|
||||
voteCounts,
|
||||
@@ -111,10 +111,9 @@ export function Dump() {
|
||||
setDumpState({ status: "loading" });
|
||||
setOp(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (!apiResponse.success) {
|
||||
@@ -135,7 +134,7 @@ export function Dump() {
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [selectedDump, preloaded, token]);
|
||||
}, [selectedDump, preloaded, authFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
@@ -154,9 +153,8 @@ export function Dump() {
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
||||
authFetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
@@ -168,15 +166,14 @@ export function Dump() {
|
||||
return () => controller.abort();
|
||||
// connectionEpoch: re-fetch on reconnect to backfill comments missed while
|
||||
// the socket was down.
|
||||
}, [selectedDump, token, connectionEpoch]);
|
||||
}, [selectedDump, authFetch, connectionEpoch]);
|
||||
|
||||
// Fetch related dumps (backlinks) when dump loads
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
|
||||
authFetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
@@ -186,16 +183,15 @@ export function Dump() {
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [selectedDump, token]);
|
||||
}, [selectedDump, authFetch]);
|
||||
|
||||
// Fetch the collections this dump appears in (public ones, plus the
|
||||
// viewer's own private ones — the server does the filtering)
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
|
||||
authFetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
@@ -208,7 +204,7 @@ export function Dump() {
|
||||
// lastPlaylistEvent: adding/removing this dump, flipping a collection's
|
||||
// visibility or deleting it all change the list — playlist events are rare
|
||||
// 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}
|
||||
useEffect(() => {
|
||||
@@ -497,7 +493,7 @@ export function Dump() {
|
||||
richContent={dump.richContent}
|
||||
dumpHref={dumpUrl(dump)}
|
||||
thumbnailOverrideUrl={dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ type DumpEditState =
|
||||
export function DumpEdit() {
|
||||
const { selectedDump } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { authFetch, token } = useRequiredAuth();
|
||||
const { authFetch, token, mediaToken } = useRequiredAuth();
|
||||
|
||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||
|
||||
@@ -195,12 +195,12 @@ export function DumpEdit() {
|
||||
const { dump } = state;
|
||||
|
||||
const currentThumbnailSrc = dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: dump.kind === "file"
|
||||
? (dump.fileMime?.startsWith("image/")
|
||||
? dumpFileUrl(dump, token)
|
||||
? dumpFileUrl(dump, mediaToken)
|
||||
: dump.fileMime?.startsWith("video/")
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: null)
|
||||
: dump.richContent?.thumbnailUrl ?? null;
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
Role,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
deserializeDump,
|
||||
deserializeInviteTreeEntry,
|
||||
deserializePublicUser,
|
||||
@@ -235,7 +234,7 @@ function buildInviteTree(
|
||||
export function UserPublicProfile() {
|
||||
const { username } = useParams();
|
||||
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 [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
|
||||
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
|
||||
@@ -734,13 +733,7 @@ export function UserPublicProfile() {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
login({
|
||||
...deserializeAuthResponse(JSON.parse(storedRaw)),
|
||||
user: deserializePublicUser(body.data),
|
||||
});
|
||||
}
|
||||
updateSession({ user: deserializePublicUser(body.data) });
|
||||
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
@@ -756,11 +749,7 @@ export function UserPublicProfile() {
|
||||
};
|
||||
|
||||
const handleEmailSaved = (email: string) => {
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
|
||||
login({ ...prev, user: { ...prev.user, email } });
|
||||
}
|
||||
if (me) updateSession({ user: { ...me, email } });
|
||||
setEmailEditing(false);
|
||||
};
|
||||
|
||||
@@ -1403,8 +1392,8 @@ function DumpList(
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
isOwnProfile?: boolean;
|
||||
viewAllHref: string;
|
||||
},
|
||||
@@ -1482,8 +1471,8 @@ function UpvotedDumpList(
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
viewAllHref: string;
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -38,8 +38,8 @@ interface FollowedFeedProps {
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
deletedDumpIds: Set<string>;
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ interface FollowedSubFeedProps {
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
deletedDumpIds: Set<string>;
|
||||
emptyMessage: string;
|
||||
onLoadMore: () => void;
|
||||
|
||||
@@ -11,6 +11,6 @@ export interface MainFeedProps {
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User | null;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
@@ -190,6 +190,16 @@
|
||||
--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 ─────────────────────────────────────── */
|
||||
[data-style="brutalist"] .dump-post-header {
|
||||
border: 2px solid var(--color-border);
|
||||
|
||||
@@ -268,6 +268,16 @@
|
||||
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
|
||||
between sub-blocks with a sparkle sitting in the middle of it. */
|
||||
[data-style="geocities"] .journal-break {
|
||||
|
||||
@@ -161,8 +161,14 @@
|
||||
}
|
||||
|
||||
/* ── On the scrim, links hover in the dark scheme's blue ─────────── */
|
||||
[data-style="kanagawa"] .journal-card--image .journal-card-title:hover {
|
||||
color: var(--kngw-accent-on-photo);
|
||||
/* …on a plate of sumiInk, the Wave page itself, in either scheme: the blue
|
||||
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"]
|
||||
|
||||
@@ -314,6 +314,16 @@
|
||||
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 ──── */
|
||||
[data-style="melange"] .journal-card--quote {
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -131,8 +131,18 @@
|
||||
}
|
||||
/* …except over a photo, where the headline is white on a dark scrim: ink
|
||||
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 {
|
||||
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). */
|
||||
[data-style="nyt"]
|
||||
|
||||
@@ -99,3 +99,17 @@
|
||||
--btn-primary-shadow: 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
59
src/utils/authStorage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user