v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
@@ -489,6 +510,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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { deserializeAuthResponse } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
@@ -25,7 +26,7 @@ interface Values {
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const { authFetch, login } = useAuth();
|
||||
const [done, setDone] = useState(false);
|
||||
const form = useApiForm<Values>({
|
||||
mode: "onChange",
|
||||
@@ -33,13 +34,19 @@ 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 }),
|
||||
}),
|
||||
);
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), ...tokens });
|
||||
}
|
||||
setDone(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -389,7 +389,7 @@ export function DumpCreateModal(
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const encoded = encodeURIComponent(canonicalUrl);
|
||||
|
||||
fetch(`${API_URL}/api/preview?url=${encoded}`)
|
||||
authFetch(`${API_URL}/api/preview?url=${encoded}`)
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
const data = body.success
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -99,6 +99,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 };
|
||||
localStorage.setItem("authResponse", JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
})
|
||||
.catch(() => {/* keep using the session token until next load */});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, needsMediaToken]);
|
||||
|
||||
const value: AuthContextValue = { authResponse, setAuthResponse };
|
||||
|
||||
return (
|
||||
|
||||
@@ -116,6 +116,10 @@ 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,
|
||||
authFetch,
|
||||
@@ -124,12 +128,13 @@ export const useAuth = () => {
|
||||
};
|
||||
|
||||
export const useRequiredAuth = () => {
|
||||
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
||||
const { user, token, mediaToken, login, logout, 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, authFetch, authUpload };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -497,7 +497,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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user