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 up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
|
||||||
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||||
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
||||||
|
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,6 +41,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
},
|
},
|
||||||
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||||
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
||||||
|
{ name: "0015_token_revocation", up: up0015TokenRevocation },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
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,
|
description TEXT,
|
||||||
invited_by TEXT REFERENCES users(id),
|
invited_by TEXT REFERENCES users(id),
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
chat_read_at TEXT
|
chat_read_at TEXT,
|
||||||
|
tokens_valid_after TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE votes (
|
CREATE TABLE votes (
|
||||||
|
|||||||
@@ -1,6 +1,67 @@
|
|||||||
import type { Context } from "@oak/oak";
|
import type { Context } from "@oak/oak";
|
||||||
import { verifyJWT } from "./jwt.ts";
|
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import {
|
||||||
|
APIErrorCode,
|
||||||
|
APIException,
|
||||||
|
type AuthPayload,
|
||||||
|
type Role,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import { db } from "../model/db.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a signature-verified token against the live user row: the user must
|
||||||
|
* still exist, and the token must not predate their last password change
|
||||||
|
* (`users.tokens_valid_after`). Returns the live role, or null to refuse.
|
||||||
|
*
|
||||||
|
* `iat` has one-second resolution, so a token issued in the same second as the
|
||||||
|
* change survives it; one issued after it (the fresh one the change hands back)
|
||||||
|
* always does.
|
||||||
|
*/
|
||||||
|
function checkTokenOwner(
|
||||||
|
userId: string,
|
||||||
|
iat: number | undefined,
|
||||||
|
): { role: Role } | null {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
|
||||||
|
).get(userId) as
|
||||||
|
| { role: Role; tokens_valid_after: string | null }
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.tokens_valid_after) {
|
||||||
|
const cutoff = Math.floor(
|
||||||
|
new Date(row.tokens_valid_after).getTime() / 1000,
|
||||||
|
);
|
||||||
|
if ((iat ?? 0) < cutoff) return null;
|
||||||
|
}
|
||||||
|
return { role: row.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a session token end to end: signature, expiry, a user that still
|
||||||
|
* exists and no password change since it was issued. The role comes from the
|
||||||
|
* DB, not the token, since tokens last 7 days and roles change under them.
|
||||||
|
*/
|
||||||
|
export async function authenticate(
|
||||||
|
token: string,
|
||||||
|
): Promise<AuthPayload | null> {
|
||||||
|
const payload = await verifyJWT(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
const live = checkTokenOwner(payload.userId, payload.iat);
|
||||||
|
if (!live) return null;
|
||||||
|
return { ...payload, role: live.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the `?token=` on a media URL to the viewer's user id. Takes a media
|
||||||
|
* token, or a session token from a client that predates them.
|
||||||
|
*/
|
||||||
|
export async function authenticateMedia(token: string): Promise<string | null> {
|
||||||
|
const media = await verifyMediaToken(token);
|
||||||
|
if (media) {
|
||||||
|
return checkTokenOwner(media.userId, media.iat) ? media.userId : null;
|
||||||
|
}
|
||||||
|
return (await authenticate(token))?.userId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the userId from an optional Bearer token.
|
* Extracts the userId from an optional Bearer token.
|
||||||
@@ -8,10 +69,10 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|||||||
* Distinguishes *missing* credentials from *invalid* ones:
|
* Distinguishes *missing* credentials from *invalid* ones:
|
||||||
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
||||||
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
|
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
|
||||||
* - A real token that fails verification (expired / bad signature) → throws 401,
|
* - A real token that fails verification (expired / bad signature / revoked) →
|
||||||
* so a stale client session surfaces as an auth error it can react to, instead
|
* throws 401, so a stale client session surfaces as an auth error it can react
|
||||||
* of being silently downgraded to anonymous (which yields a misleading 404 on
|
* to, instead of being silently downgraded to anonymous (which yields a
|
||||||
* private resources).
|
* misleading 404 on private resources).
|
||||||
*/
|
*/
|
||||||
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
@@ -20,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
|||||||
const token = authHeader.substring(7).trim();
|
const token = authHeader.substring(7).trim();
|
||||||
if (!token || token === "undefined" || token === "null") return null;
|
if (!token || token === "undefined" || token === "null") return null;
|
||||||
|
|
||||||
const payload = await verifyJWT(token);
|
const payload = await authenticate(token);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomBytes, scrypt } from "node:crypto";
|
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
import { jwtVerify, SignJWT } from "@panva/jose";
|
import { jwtVerify, SignJWT } from "@panva/jose";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -47,10 +48,40 @@ export async function createJWT(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await new SignJWT(payload)
|
return await new SignJWT(payload)
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
// `iat` is what a password change revokes against (users.tokens_valid_after).
|
||||||
|
.setIssuedAt()
|
||||||
.setExpirationTime("7d")
|
.setExpirationTime("7d")
|
||||||
.sign(JWT_KEY);
|
.sign(JWT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Media tokens ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Media elements can't send an Authorization header, so a private dump's file
|
||||||
|
// and thumbnail URLs carry a token in the query string, where it lands in
|
||||||
|
// access logs and browser history. That token is this one: it only reads media,
|
||||||
|
// so a leaked URL exposes the owner's private files, not their account.
|
||||||
|
export async function createMediaToken(userId: string): Promise<string> {
|
||||||
|
return await new SignJWT({ purpose: "media", userId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("7d")
|
||||||
|
.sign(JWT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMediaToken(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ userId: string; iat?: number } | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||||
|
if (payload.purpose !== "media" || typeof payload.userId !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { userId: payload.userId, iat: payload.iat };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||||
@@ -86,7 +117,13 @@ export function verifyPassword(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(hash === derivedKey.toString("hex"));
|
else {
|
||||||
|
const expected = Buffer.from(hash ?? "", "hex");
|
||||||
|
resolve(
|
||||||
|
expected.length === derivedKey.length &&
|
||||||
|
timingSafeEqual(expected, derivedKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
137
api/lib/public-address.ts
Normal file
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,
|
filePath: string,
|
||||||
mime: string,
|
mime: string,
|
||||||
cacheMaxAge = 3600,
|
cacheMaxAge = 3600,
|
||||||
|
isPrivate = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
let data: Uint8Array;
|
let data: Uint8Array;
|
||||||
try {
|
try {
|
||||||
@@ -75,7 +76,11 @@ export async function serveUploadedFile(
|
|||||||
}
|
}
|
||||||
ctx.response.headers.set("Content-Type", mime);
|
ctx.response.headers.set("Content-Type", mime);
|
||||||
ctx.response.headers.set("Content-Disposition", "inline");
|
ctx.response.headers.set("Content-Disposition", "inline");
|
||||||
ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`);
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
ctx.response.headers.set(
|
||||||
|
"Cache-Control",
|
||||||
|
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||||
|
);
|
||||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||||
ctx.response.body = data;
|
ctx.response.body = data;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import {
|
|||||||
type RouterContext,
|
type RouterContext,
|
||||||
type State,
|
type State,
|
||||||
} from "@oak/oak";
|
} from "@oak/oak";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { authenticate } from "../lib/auth.ts";
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
APIException,
|
||||||
type AuthPayload,
|
type AuthPayload,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { getUserById } from "../services/user-service.ts";
|
|
||||||
import { can, type Permission } from "../lib/permissions.ts";
|
import { can, type Permission } from "../lib/permissions.ts";
|
||||||
|
|
||||||
export interface AuthState extends State {
|
export interface AuthState extends State {
|
||||||
@@ -40,23 +39,15 @@ export async function authMiddleware<R extends string>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const payload = await verifyJWT(token);
|
// Also checks the user still exists and hasn't changed their password since
|
||||||
|
// the token was issued, and takes the live role from the DB.
|
||||||
|
const payload = await authenticate(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||||
}
|
}
|
||||||
|
|
||||||
let user;
|
ctx.state.user = payload;
|
||||||
try {
|
|
||||||
user = getUserById(payload.userId);
|
|
||||||
} catch {
|
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trust the live role from the DB, not the role baked into the JWT at login.
|
|
||||||
// Tokens last 7 days, so a role change (e.g. promotion to admin or a demotion)
|
|
||||||
// would otherwise not take effect until the user re-logs in.
|
|
||||||
ctx.state.user = { ...payload, role: user.role };
|
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,11 +220,14 @@ export interface AuthPayload {
|
|||||||
username: string;
|
username: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
exp: number;
|
exp: number;
|
||||||
|
iat?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
|
// Purpose-scoped tokens (password reset, media) are never session tokens.
|
||||||
|
!("purpose" in obj) &&
|
||||||
"userId" in obj && typeof obj.userId === "string" &&
|
"userId" in obj && typeof obj.userId === "string" &&
|
||||||
"username" in obj && typeof obj.username === "string" &&
|
"username" in obj && typeof obj.username === "string" &&
|
||||||
"role" in obj && isRole(obj.role) &&
|
"role" in obj && isRole(obj.role) &&
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
|||||||
|
|
||||||
const router = new Router({ prefix: "/api/dumps" });
|
const router = new Router({ prefix: "/api/dumps" });
|
||||||
|
|
||||||
|
// Every route here takes a slug or an id; the services below them key on the
|
||||||
|
// id, so mutations always pass `dump.id` from the resolved dump, never the raw
|
||||||
|
// parameter.
|
||||||
|
|
||||||
// Categories arrive on the multipart create path as a JSON-encoded string field.
|
// Categories arrive on the multipart create path as a JSON-encoded string field.
|
||||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||||
if (typeof value !== "string" || !value) return [];
|
if (typeof value !== "string" || !value) return [];
|
||||||
@@ -193,7 +197,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedDump = await replaceFileDump(
|
const updatedDump = await replaceFileDump(
|
||||||
dumpId,
|
dump.id,
|
||||||
file,
|
file,
|
||||||
typeof comment === "string" && comment ? comment : undefined,
|
typeof comment === "string" && comment ? comment : undefined,
|
||||||
typeof title === "string" && title ? title : undefined,
|
typeof title === "string" && title ? title : undefined,
|
||||||
@@ -228,7 +232,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDump = await updateDump(dumpId, body);
|
const updatedDump = await updateDump(dump.id, body);
|
||||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -306,7 +310,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDump = await refreshDumpMetadata(dumpId);
|
const updatedDump = await refreshDumpMetadata(dump.id);
|
||||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -324,7 +328,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDump(dumpId);
|
await deleteDump(dump.id);
|
||||||
|
|
||||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from "@oak/oak";
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import { getDump } from "../services/dump-service.ts";
|
import { getDump } from "../services/dump-service.ts";
|
||||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { authenticateMedia } from "../lib/auth.ts";
|
||||||
|
|
||||||
const router = new Router({ prefix: "/api/files" });
|
const router = new Router({ prefix: "/api/files" });
|
||||||
|
|
||||||
@@ -14,15 +14,31 @@ const router = new Router({ prefix: "/api/files" });
|
|||||||
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
||||||
* full UTF-8 name for modern browsers.
|
* full UTF-8 name for modern browsers.
|
||||||
*/
|
*/
|
||||||
function contentDisposition(name: string): string {
|
function contentDisposition(
|
||||||
|
type: "inline" | "attachment",
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
const asciiFallback = name
|
const asciiFallback = name
|
||||||
.replace(/["\\]/g, "_")
|
.replace(/["\\]/g, "_")
|
||||||
.replace(/[^\x20-\x7e]/g, "_");
|
.replace(/[^\x20-\x7e]/g, "_");
|
||||||
return `inline; filename="${asciiFallback}"; filename*=UTF-8''${
|
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
|
||||||
encodeURIComponent(name)
|
encodeURIComponent(name)
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The MIME type of an upload is whatever the uploader's browser claimed, and
|
||||||
|
// these responses come from the app's own origin — where the session token
|
||||||
|
// lives. Only types a browser renders without running the file's own code are
|
||||||
|
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
|
||||||
|
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
|
||||||
|
// or read the file still work.
|
||||||
|
const INLINE_MIME_RE =
|
||||||
|
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
|
||||||
|
|
||||||
|
function isInlineSafe(mime: string): boolean {
|
||||||
|
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/:dumpId", async (ctx) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
const { dumpId } = ctx.params;
|
const { dumpId } = ctx.params;
|
||||||
|
|
||||||
@@ -35,8 +51,8 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
// arrives as a query param (same as the WebSocket route). Needed to authorize
|
// arrives as a query param (same as the WebSocket route). Needed to authorize
|
||||||
// access to private dumps the requester owns.
|
// access to private dumps the requester owns.
|
||||||
const token = ctx.request.url.searchParams.get("token");
|
const token = ctx.request.url.searchParams.get("token");
|
||||||
const payload = token ? await verifyJWT(token) : null;
|
const viewerId = token ? await authenticateMedia(token) : null;
|
||||||
const dump = getDump(dumpId, payload?.userId);
|
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||||
|
|
||||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -56,11 +72,27 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = data.byteLength;
|
const total = data.byteLength;
|
||||||
|
const inline = isInlineSafe(dump.fileMime);
|
||||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
contentDisposition(dump.fileName),
|
contentDisposition(inline ? "inline" : "attachment", dump.fileName),
|
||||||
);
|
);
|
||||||
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
// Belt and braces for a file opened directly: no scripts, no plugins, no
|
||||||
|
// same-origin access. Chrome won't render a PDF in a sandboxed document, and
|
||||||
|
// its viewer never runs a PDF's scripts anyway, so PDFs go without.
|
||||||
|
if (dump.fileMime !== "application/pdf") {
|
||||||
|
ctx.response.headers.set(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The URL may carry a media token; don't pass it on to wherever the file
|
||||||
|
// links.
|
||||||
|
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||||
|
// A private dump's bytes must not sit in a shared cache.
|
||||||
|
if (dump.isPrivate) ctx.response.headers.set("Cache-Control", "private");
|
||||||
ctx.response.headers.set("Accept-Ranges", "bytes");
|
ctx.response.headers.set("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
const rangeHeader = ctx.request.headers.get("Range");
|
const rangeHeader = ctx.request.headers.get("Range");
|
||||||
|
|||||||
@@ -5,10 +5,24 @@ import {
|
|||||||
tryFetchRichContent,
|
tryFetchRichContent,
|
||||||
} from "../services/rich-content-service.ts";
|
} from "../services/rich-content-service.ts";
|
||||||
import { APIErrorCode } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../model/interfaces.ts";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
|
||||||
const previewRouter = new Router();
|
const previewRouter = new Router();
|
||||||
|
|
||||||
previewRouter.get("/api/preview", async (ctx) => {
|
const PROXYABLE_IMAGE_TYPES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
"image/bmp",
|
||||||
|
"image/x-icon",
|
||||||
|
"image/vnd.microsoft.icon",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
||||||
|
// use the server to fetch pages for them.
|
||||||
|
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||||
if (!isValidHttpUrl(url)) {
|
if (!isValidHttpUrl(url)) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
@@ -42,8 +56,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url, 8000);
|
const res = await fetchWithTimeout(url, 8000);
|
||||||
const contentType = res.headers.get("content-type") ?? "";
|
// Raster formats only. The bytes go out under this site's origin, so an
|
||||||
if (!contentType.startsWith("image/")) {
|
// SVG (which can carry script) or anything else a browser might execute
|
||||||
|
// would run with the app's origin if opened directly.
|
||||||
|
const contentType = (res.headers.get("content-type") ?? "")
|
||||||
|
.split(";")[0].trim().toLowerCase();
|
||||||
|
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
|
||||||
|
await res.body?.cancel();
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,6 +70,7 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
|||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
||||||
if (contentLength > MAX_SIZE) {
|
if (contentLength > MAX_SIZE) {
|
||||||
|
await res.body?.cancel();
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,6 +82,11 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.headers.set("Content-Type", contentType);
|
ctx.response.headers.set("Content-Type", contentType);
|
||||||
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
ctx.response.headers.set(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"sandbox; default-src 'none'",
|
||||||
|
);
|
||||||
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
|
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
|
||||||
ctx.response.body = bytes;
|
ctx.response.body = bytes;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|||||||
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
|
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
|
||||||
import { serveUploadedFile } from "../lib/upload.ts";
|
import { serveUploadedFile } from "../lib/upload.ts";
|
||||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { authenticateMedia } from "../lib/auth.ts";
|
||||||
|
|
||||||
const router = new Router({ prefix: "/api/thumbnails" });
|
const router = new Router({ prefix: "/api/thumbnails" });
|
||||||
|
|
||||||
@@ -28,6 +28,14 @@ async function generateThumbnail(
|
|||||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||||
const cmd = new Deno.Command("ffmpeg", {
|
const cmd = new Deno.Command("ffmpeg", {
|
||||||
args: [
|
args: [
|
||||||
|
// The upload's type is only what the uploader claimed, and ffmpeg probes
|
||||||
|
// the real format from the bytes — a "video" could be an HLS or concat
|
||||||
|
// playlist pointing ffmpeg at local files or internal URLs. Read nothing
|
||||||
|
// but the file itself, and only as an actual video container.
|
||||||
|
"-protocol_whitelist",
|
||||||
|
"file",
|
||||||
|
"-format_whitelist",
|
||||||
|
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc",
|
||||||
"-ss",
|
"-ss",
|
||||||
"00:00:01",
|
"00:00:01",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -58,8 +66,14 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
// Token via query param (media elements can't send headers) — authorizes the
|
// Token via query param (media elements can't send headers) — authorizes the
|
||||||
// owner's private dumps and gates the custom-thumbnail branch below too.
|
// owner's private dumps and gates the custom-thumbnail branch below too.
|
||||||
const token = ctx.request.url.searchParams.get("token");
|
const token = ctx.request.url.searchParams.get("token");
|
||||||
const payload = token ? await verifyJWT(token) : null;
|
const viewerId = token ? await authenticateMedia(token) : null;
|
||||||
const dump = getDump(dumpId, payload?.userId);
|
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||||
|
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||||
|
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||||
|
// A private dump's thumbnail must not sit in a shared cache.
|
||||||
|
const cacheControl = dump.isPrivate
|
||||||
|
? "private, max-age=31536000, immutable"
|
||||||
|
: "public, max-age=31536000, immutable";
|
||||||
|
|
||||||
const customThumb = getDumpThumbnailInfo(dumpId);
|
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||||
if (customThumb) {
|
if (customThumb) {
|
||||||
@@ -67,6 +81,8 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
ctx,
|
ctx,
|
||||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||||
customThumb.mime,
|
customThumb.mime,
|
||||||
|
3600,
|
||||||
|
dump.isPrivate,
|
||||||
);
|
);
|
||||||
if (served) return;
|
if (served) return;
|
||||||
}
|
}
|
||||||
@@ -85,10 +101,7 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
try {
|
try {
|
||||||
const data = await Deno.readFile(thumbPath);
|
const data = await Deno.readFile(thumbPath);
|
||||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||||
"Cache-Control",
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
);
|
|
||||||
ctx.response.body = data;
|
ctx.response.body = data;
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -116,10 +129,7 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
|
|
||||||
const data = await Deno.readFile(thumbPath);
|
const data = await Deno.readFile(thumbPath);
|
||||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||||
"Cache-Control",
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
);
|
|
||||||
ctx.response.body = data;
|
ctx.response.body = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
isRole,
|
isRole,
|
||||||
isUpdateUserRequest,
|
isUpdateUserRequest,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
|
type User,
|
||||||
validateRegisterUserRequest,
|
validateRegisterUserRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
import { createJWT, verifyPassword } from "../lib/jwt.ts";
|
import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
|
||||||
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||||
import {
|
import {
|
||||||
type AuthContext,
|
type AuthContext,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
@@ -26,7 +28,7 @@ import {
|
|||||||
searchUsers,
|
searchUsers,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
|
||||||
import {
|
import {
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
@@ -53,6 +55,22 @@ import {
|
|||||||
// Users router
|
// Users router
|
||||||
const router = new Router({ prefix: "/api/users" });
|
const router = new Router({ prefix: "/api/users" });
|
||||||
|
|
||||||
|
// A burst of 10 attempts, then one every 30s.
|
||||||
|
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||||
|
|
||||||
|
// The session token, plus the media-only token that goes in file and
|
||||||
|
// thumbnail URLs (see createMediaToken).
|
||||||
|
async function issueTokens(user: User) {
|
||||||
|
return {
|
||||||
|
token: await createJWT({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
}),
|
||||||
|
mediaToken: await createMediaToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Register a new user (requires a valid invite token)
|
// Register a new user (requires a valid invite token)
|
||||||
router.post("/register", async (ctx) => {
|
router.post("/register", async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
@@ -62,16 +80,16 @@ router.post("/register", async (ctx) => {
|
|||||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate invite — throws 404/409 if bad
|
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||||
const inviterId = validateInvite(body.inviteToken);
|
// account can't be created, so one invite can't open two accounts.
|
||||||
|
const inviterId = claimInvite(body.inviteToken);
|
||||||
|
|
||||||
const user = await createUser(body, inviterId);
|
let user;
|
||||||
|
|
||||||
// Mark invite as used only after the user row is committed
|
|
||||||
try {
|
try {
|
||||||
redeemInvite(body.inviteToken);
|
user = await createUser(body, inviterId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[register] redeemInvite failed (user created):", err);
|
releaseInvite(body.inviteToken);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send welcome email (fire-and-forget)
|
// Send welcome email (fire-and-forget)
|
||||||
@@ -88,23 +106,16 @@ router.post("/register", async (ctx) => {
|
|||||||
}).catch((err) => console.error("[register] welcome email failed:", err));
|
}).catch((err) => console.error("[register] welcome email failed:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
const authToken = await createJWT({
|
|
||||||
userId: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { passwordHash: _, ...publicUser } = user;
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { token: authToken, user: publicUser },
|
data: { ...await issueTokens(user), user: publicUser },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
router.post("/login", async (ctx) => {
|
router.post("/login", async (ctx) => {
|
||||||
try {
|
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
if (!isLoginUserRequest(body)) {
|
if (!isLoginUserRequest(body)) {
|
||||||
@@ -115,9 +126,29 @@ router.post("/login", async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = getUserByUsername(body.username);
|
// Per account, so guessing one user's password is slow however many
|
||||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
// connections it comes from.
|
||||||
|
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
429,
|
||||||
|
"Too many login attempts — try again in a minute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = getUserByUsername(body.username);
|
||||||
|
} catch {
|
||||||
|
// Same answer as a wrong password: don't confirm which usernames exist.
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
401,
|
||||||
|
"Invalid username or password",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
@@ -126,25 +157,11 @@ router.post("/login", async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await createJWT({
|
|
||||||
userId: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { passwordHash: _, ...publicUser } = user;
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { ...await issueTokens(user), user: publicUser },
|
||||||
token,
|
|
||||||
user: publicUser,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current user profile
|
// Get current user profile
|
||||||
@@ -177,6 +194,14 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A media token for a session that predates them (see createMediaToken).
|
||||||
|
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Request a password reset email (unauthenticated; always returns 200)
|
// Request a password reset email (unauthenticated; always returns 200)
|
||||||
router.post("/request-password-reset", async (ctx) => {
|
router.post("/request-password-reset", async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
@@ -241,8 +266,12 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateUser(ctx.state.user.userId, { password: newPassword });
|
// Changing the password revokes every token issued before it, this
|
||||||
ctx.response.body = { success: true };
|
// session's included, so hand back fresh ones for the client to keep.
|
||||||
|
const updated = await updateUser(ctx.state.user.userId, {
|
||||||
|
password: newPassword,
|
||||||
|
});
|
||||||
|
ctx.response.body = { success: true, data: await issueTokens(updated) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update current user profile (description, etc.)
|
// Update current user profile (description, etc.)
|
||||||
@@ -255,10 +284,14 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
|||||||
"Invalid request",
|
"Invalid request",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Self-service profile updates must never set the role. Role assignment is
|
// Only the profile fields. The role is assigned by an admin, the password
|
||||||
// done out-of-band (DB/seed); stripping it here prevents self-promotion.
|
// changes through /me/change-password (which asks for the current one), and
|
||||||
const { role: _role, ...safeBody } = body;
|
// the username is fixed — a token alone must not be able to change any of
|
||||||
const updated = await updateUser(ctx.state.user.userId, safeBody);
|
// them.
|
||||||
|
const updated = await updateUser(ctx.state.user.userId, {
|
||||||
|
description: body.description,
|
||||||
|
email: body.email,
|
||||||
|
});
|
||||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||||
broadcastUserUpdated(publicUser);
|
broadcastUserUpdated(publicUser);
|
||||||
ctx.response.body = { success: true, data: publicUser };
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
|
|||||||
139
api/routes/ws.ts
139
api/routes/ws.ts
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { authenticate } from "../lib/auth.ts";
|
||||||
import {
|
import {
|
||||||
broadcastChatMessage,
|
broadcastChatMessage,
|
||||||
broadcastChatMessageDeleted,
|
broadcastChatMessageDeleted,
|
||||||
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
|
|||||||
import {
|
import {
|
||||||
APIException,
|
APIException,
|
||||||
type ClientToServerMessage,
|
type ClientToServerMessage,
|
||||||
|
type User,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -64,19 +65,24 @@ router.get("/ws", async (ctx) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authenticate() refuses a token whose user is gone or which predates a
|
||||||
|
// password change, and reads the live role from the DB. Such a socket is
|
||||||
|
// simply anonymous; the client's next API call gets the 401.
|
||||||
const token = ctx.request.url.searchParams.get("token");
|
const token = ctx.request.url.searchParams.get("token");
|
||||||
const authPayload = token ? await verifyJWT(token) : null;
|
const authPayload = token ? await authenticate(token) : null;
|
||||||
|
let user: User | undefined;
|
||||||
|
try {
|
||||||
|
user = authPayload ? getUserById(authPayload.userId) : undefined;
|
||||||
|
} catch {
|
||||||
|
user = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const socket = ctx.upgrade();
|
const socket = ctx.upgrade();
|
||||||
|
|
||||||
// Read the live user so the role reflects the DB, not the (possibly stale)
|
|
||||||
// role embedded in the 7-day JWT.
|
|
||||||
const user = authPayload ? getUserById(authPayload.userId) : undefined;
|
|
||||||
|
|
||||||
const client: WsClient = {
|
const client: WsClient = {
|
||||||
socket,
|
socket,
|
||||||
userId: authPayload?.userId,
|
userId: user?.id,
|
||||||
username: authPayload?.username,
|
username: user?.username,
|
||||||
role: user?.role,
|
role: user?.role,
|
||||||
avatarMime: user?.avatarMime,
|
avatarMime: user?.avatarMime,
|
||||||
};
|
};
|
||||||
@@ -87,16 +93,10 @@ router.get("/ws", async (ctx) => {
|
|||||||
broadcastPresence();
|
broadcastPresence();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
const myVotes = user ? getUserVotes(user.id) : [];
|
||||||
const myCommentLikes = authPayload
|
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
|
||||||
? getUserCommentLikes(authPayload.userId)
|
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
|
||||||
: [];
|
const chatRead = user ? getChatReadState(user.id) : undefined;
|
||||||
const unreadNotificationCount = authPayload
|
|
||||||
? getUnreadCount(authPayload.userId)
|
|
||||||
: 0;
|
|
||||||
const chatRead = authPayload
|
|
||||||
? getChatReadState(authPayload.userId)
|
|
||||||
: undefined;
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: "welcome",
|
type: "welcome",
|
||||||
users: getOnlineUsers(),
|
users: getOnlineUsers(),
|
||||||
@@ -112,16 +112,43 @@ router.get("/ws", async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
let msg: ClientToServerMessage;
|
// An exception escaping a WebSocket listener is uncaught in Deno and ends
|
||||||
|
// the process, so nothing a client sends may throw out of here.
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(event.data as string);
|
handleMessage(client, event.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ws] message handler failed:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
unregister(client);
|
||||||
|
broadcastPresence();
|
||||||
|
// If they were mid-compose, drop them from the typing set right away.
|
||||||
|
if (client.typingAt) broadcastTyping();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMessage(client: WsClient, data: unknown): void {
|
||||||
|
if (typeof data !== "string") return;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(data);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Anything but an object with a string `type` (`null`, a number, an array)
|
||||||
|
// is ignored rather than read from.
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" || parsed === null ||
|
||||||
|
typeof (parsed as { type?: unknown }).type !== "string"
|
||||||
|
) return;
|
||||||
|
const msg = parsed as ClientToServerMessage;
|
||||||
|
const { socket } = client;
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "ping":
|
case "ping":
|
||||||
socket.send(JSON.stringify({ type: "pong" }));
|
send(socket, { type: "pong" });
|
||||||
break;
|
break;
|
||||||
case "pong":
|
case "pong":
|
||||||
handleClientPong(client);
|
handleClientPong(client);
|
||||||
@@ -163,80 +190,89 @@ router.get("/ws", async (ctx) => {
|
|||||||
handleChatRead(client, msg.at);
|
handleChatRead(client, msg.at);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||||
unregister(client);
|
function send(socket: WebSocket, data: Record<string, unknown>): void {
|
||||||
broadcastPresence();
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||||
// If they were mid-compose, drop them from the typing set right away.
|
}
|
||||||
if (client.typingAt) broadcastTyping();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleVote(
|
function handleVote(
|
||||||
client: WsClient,
|
client: WsClient,
|
||||||
dumpId: string,
|
dumpId: unknown,
|
||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
): void {
|
): void {
|
||||||
const { socket } = client;
|
const { socket } = client;
|
||||||
|
|
||||||
if (!client.userId) {
|
if (!client.userId) {
|
||||||
socket.send(
|
send(socket, { type: "error", message: "Authentication required" });
|
||||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof dumpId !== "string") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCount = action === "cast"
|
const { voteCount, isPrivate } = action === "cast"
|
||||||
? castVote(dumpId, client.userId)
|
? castVote(dumpId, client.userId)
|
||||||
: removeVote(dumpId, client.userId);
|
: removeVote(dumpId, client.userId);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
send(socket, {
|
||||||
type: "vote_ack",
|
type: "vote_ack",
|
||||||
dumpId,
|
dumpId,
|
||||||
action,
|
action,
|
||||||
success: true,
|
success: true,
|
||||||
voteCount: newCount,
|
voteCount,
|
||||||
}));
|
});
|
||||||
|
|
||||||
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
// Only a private dump's owner can vote on it, so they're its whole
|
||||||
|
// audience.
|
||||||
|
broadcastVoteUpdate(
|
||||||
|
dumpId,
|
||||||
|
voteCount,
|
||||||
|
client.userId,
|
||||||
|
action,
|
||||||
|
isPrivate ? client.userId : undefined,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||||
socket.send(JSON.stringify({ type: "error", message, dumpId, action }));
|
send(socket, { type: "error", message, dumpId, action });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommentLike(
|
function handleCommentLike(
|
||||||
client: WsClient,
|
client: WsClient,
|
||||||
commentId: string,
|
commentId: unknown,
|
||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
): void {
|
): void {
|
||||||
const { socket } = client;
|
const { socket } = client;
|
||||||
|
|
||||||
if (!client.userId) {
|
if (!client.userId) {
|
||||||
socket.send(
|
send(socket, { type: "error", message: "Authentication required" });
|
||||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof commentId !== "string") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCount = action === "cast"
|
const { likeCount, isPrivate } = action === "cast"
|
||||||
? castCommentLike(commentId, client.userId)
|
? castCommentLike(commentId, client.userId)
|
||||||
: removeCommentLike(commentId, client.userId);
|
: removeCommentLike(commentId, client.userId);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
send(socket, {
|
||||||
type: "comment_like_ack",
|
type: "comment_like_ack",
|
||||||
commentId,
|
commentId,
|
||||||
action,
|
action,
|
||||||
likeCount: newCount,
|
likeCount,
|
||||||
}));
|
});
|
||||||
|
|
||||||
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
|
broadcastCommentLikeUpdate(
|
||||||
|
commentId,
|
||||||
|
likeCount,
|
||||||
|
client.userId,
|
||||||
|
action,
|
||||||
|
isPrivate ? client.userId : undefined,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof APIException ? err.message : "Like failed";
|
const message = err instanceof APIException ? err.message : "Like failed";
|
||||||
socket.send(JSON.stringify({ type: "error", message, commentId, action }));
|
send(socket, { type: "error", message, commentId, action });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,10 +289,7 @@ function sendChatError(
|
|||||||
message: string,
|
message: string,
|
||||||
nonce?: string,
|
nonce?: string,
|
||||||
): void {
|
): void {
|
||||||
if (client.socket.readyState !== WebSocket.OPEN) return;
|
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
||||||
client.socket.send(
|
|
||||||
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validates the common preconditions of a chat write: signed in and under the
|
// Validates the common preconditions of a chat write: signed in and under the
|
||||||
|
|||||||
@@ -252,8 +252,11 @@ export function markChatRead(userId: string, at: string): ChatReadState {
|
|||||||
const newest = db.prepare(
|
const newest = db.prepare(
|
||||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||||
).get() as { newest: string | null };
|
).get() as { newest: string | null };
|
||||||
|
// No messages yet: there's nothing to have read, and no newest message to
|
||||||
|
// cap a bogus far-future marker at — which could never be moved back.
|
||||||
|
if (!newest.newest) return getChatReadState(userId);
|
||||||
const iso = parsed.toISOString();
|
const iso = parsed.toISOString();
|
||||||
const target = newest.newest && iso > newest.newest ? newest.newest : iso;
|
const target = iso > newest.newest ? newest.newest : iso;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE users SET chat_read_at = ?
|
`UPDATE users SET chat_read_at = ?
|
||||||
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
|
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
|
||||||
|
|||||||
@@ -2,7 +2,29 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
|||||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||||
|
|
||||||
export function castCommentLike(commentId: string, userId: string): number {
|
export interface LikeResult {
|
||||||
|
likeCount: number;
|
||||||
|
/** Likes on a private dump's comments go to the dump's owner only. */
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility of the comment's dump, or a 404 when the user can't see it.
|
||||||
|
function commentVisibility(commentId: string, userId: string): boolean {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
|
||||||
|
WHERE c.id = ?;`,
|
||||||
|
).get(commentId) as { is_private: number; user_id: string } | undefined;
|
||||||
|
if (!row || (row.is_private && row.user_id !== userId)) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||||
|
}
|
||||||
|
return Boolean(row.is_private);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function castCommentLike(
|
||||||
|
commentId: string,
|
||||||
|
userId: string,
|
||||||
|
): LikeResult {
|
||||||
|
const isPrivate = commentVisibility(commentId, userId);
|
||||||
let likeCount: number;
|
let likeCount: number;
|
||||||
try {
|
try {
|
||||||
db.exec("BEGIN;");
|
db.exec("BEGIN;");
|
||||||
@@ -34,10 +56,20 @@ export function castCommentLike(commentId: string, userId: string): number {
|
|||||||
try {
|
try {
|
||||||
notifyCommentOwnerLike(userId, commentId);
|
notifyCommentOwnerLike(userId, commentId);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return likeCount;
|
return { likeCount, isPrivate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCommentLike(commentId: string, userId: string): number {
|
// No visibility check: taking back a like is harmless, and it's the user's own
|
||||||
|
// row.
|
||||||
|
export function removeCommentLike(
|
||||||
|
commentId: string,
|
||||||
|
userId: string,
|
||||||
|
): LikeResult {
|
||||||
|
const dumpRow = db.prepare(
|
||||||
|
`SELECT d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id
|
||||||
|
WHERE c.id = ?;`,
|
||||||
|
).get(commentId) as { is_private: number } | undefined;
|
||||||
|
const isPrivate = Boolean(dumpRow?.is_private);
|
||||||
try {
|
try {
|
||||||
db.exec("BEGIN;");
|
db.exec("BEGIN;");
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
@@ -54,7 +86,7 @@ export function removeCommentLike(commentId: string, userId: string): number {
|
|||||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||||
).get(commentId) as { like_count: number } | undefined;
|
).get(commentId) as { like_count: number } | undefined;
|
||||||
db.exec("COMMIT;");
|
db.exec("COMMIT;");
|
||||||
return row?.like_count ?? 0;
|
return { likeCount: row?.like_count ?? 0, isPrivate };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof APIException) throw err;
|
if (err instanceof APIException) throw err;
|
||||||
db.exec("ROLLBACK;");
|
db.exec("ROLLBACK;");
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
DUMP_MAX_FILE_SIZE_BYTES,
|
DUMP_MAX_FILE_SIZE_BYTES,
|
||||||
DUMPS_DIR,
|
DUMPS_DIR,
|
||||||
THUMBNAILS_DIR,
|
THUMBNAILS_DIR,
|
||||||
|
VALIDATION,
|
||||||
} from "../config.ts";
|
} from "../config.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
import { relinkBacklinks } from "./backlink-service.ts";
|
import { relinkBacklinks } from "./backlink-service.ts";
|
||||||
@@ -46,6 +47,25 @@ function isAllowedMime(mime: string): boolean {
|
|||||||
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
|
||||||
|
// the multipart ones (file create and replace) arrive here unchecked.
|
||||||
|
function assertFileDumpText(title?: string, comment?: string): void {
|
||||||
|
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function titleFromUrl(url: string): string {
|
function titleFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname.replace(/^www\./, "");
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
@@ -127,6 +147,7 @@ export async function createFileDump(
|
|||||||
title?: string,
|
title?: string,
|
||||||
categoryIds: string[] = [],
|
categoryIds: string[] = [],
|
||||||
): Promise<Dump> {
|
): Promise<Dump> {
|
||||||
|
assertFileDumpText(title, comment);
|
||||||
if (!isAllowedMime(file.type)) {
|
if (!isAllowedMime(file.type)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.BAD_REQUEST,
|
APIErrorCode.BAD_REQUEST,
|
||||||
@@ -489,6 +510,7 @@ export async function replaceFileDump(
|
|||||||
title?: string,
|
title?: string,
|
||||||
categoryIds?: string[],
|
categoryIds?: string[],
|
||||||
): Promise<Dump> {
|
): Promise<Dump> {
|
||||||
|
assertFileDumpText(title, comment);
|
||||||
if (!isAllowedMime(file.type)) {
|
if (!isAllowedMime(file.type)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.BAD_REQUEST,
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -60,10 +60,26 @@ export function validateInvite(token: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the token as used. Call this only after the user has been created.
|
* Validates the token and marks it used in one step, returning the inviterId.
|
||||||
|
* Claim before the (async) account creation, not after: checking first and
|
||||||
|
* marking later lets parallel registrations all pass the check on one invite.
|
||||||
*/
|
*/
|
||||||
export function redeemInvite(token: string): void {
|
export function claimInvite(token: string): string {
|
||||||
db.prepare(
|
const inviterId = validateInvite(token);
|
||||||
`UPDATE invites SET used_at = ? WHERE token = ?;`,
|
const result = db.prepare(
|
||||||
|
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||||
).run(new Date().toISOString(), token);
|
).run(new Date().toISOString(), token);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
409,
|
||||||
|
"Invite already used",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inviterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands a claimed invite back when the account it was claimed for failed. */
|
||||||
|
export function releaseInvite(token: string): void {
|
||||||
|
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
VALIDATION,
|
VALIDATION,
|
||||||
} from "../config.ts";
|
} from "../config.ts";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||||
|
|
||||||
const RESET_TOKEN_TTL_HOURS = 1;
|
const RESET_TOKEN_TTL_HOURS = 1;
|
||||||
|
|
||||||
@@ -21,7 +22,12 @@ const RESET_TOKEN_TTL_HOURS = 1;
|
|||||||
* sends the reset link. Always resolves without throwing so callers can return
|
* sends the reset link. Always resolves without throwing so callers can return
|
||||||
* 200 unconditionally (no user enumeration).
|
* 200 unconditionally (no user enumeration).
|
||||||
*/
|
*/
|
||||||
|
// Per address: enough to retry a lost email, not enough to flood an inbox.
|
||||||
|
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
|
||||||
|
|
||||||
export async function requestPasswordReset(email: string): Promise<void> {
|
export async function requestPasswordReset(email: string): Promise<void> {
|
||||||
|
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
|
||||||
|
|
||||||
const user = getUserByEmail(email);
|
const user = getUserByEmail(email);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
@@ -93,7 +99,12 @@ export async function resetPassword(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.used_at !== null) {
|
// Consume the link before the (async) password hash: checking used_at and
|
||||||
|
// marking it later would let parallel submissions all pass the check.
|
||||||
|
const claimed = db.prepare(
|
||||||
|
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||||
|
).run(new Date().toISOString(), token);
|
||||||
|
if (claimed.changes === 0) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
409,
|
409,
|
||||||
@@ -101,9 +112,11 @@ export async function resetPassword(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await updateUser(payload.userId, { password: newPassword });
|
await updateUser(payload.userId, { password: newPassword });
|
||||||
|
} catch (err) {
|
||||||
db.prepare(
|
db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`)
|
||||||
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
|
.run(token);
|
||||||
).run(new Date().toISOString(), token);
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function updatePlaylist(
|
|||||||
);
|
);
|
||||||
linkAttachments(newDescription, playlist.id);
|
linkAttachments(newDescription, playlist.id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistUpdated(updated);
|
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ export function setPlaylistImage(
|
|||||||
playlist.id,
|
playlist.id,
|
||||||
);
|
);
|
||||||
const updated = getPlaylistById(playlist.id);
|
const updated = getPlaylistById(playlist.id);
|
||||||
broadcastPlaylistUpdated(updated);
|
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +256,17 @@ export function addDumpToPlaylist(
|
|||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only a dump the requester can see — someone else's private dump would
|
||||||
|
// otherwise land in the playlist and its title in followers' notifications.
|
||||||
|
const dump = db.prepare(
|
||||||
|
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
|
||||||
|
).get(dumpId) as
|
||||||
|
| { title: string; is_private: number; user_id: string }
|
||||||
|
| undefined;
|
||||||
|
if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
|
}
|
||||||
|
|
||||||
const minRow = db.prepare(
|
const minRow = db.prepare(
|
||||||
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
||||||
).get(playlist.id) as { min_pos: number | null } | undefined;
|
).get(playlist.id) as { min_pos: number | null } | undefined;
|
||||||
@@ -283,20 +294,16 @@ export function addDumpToPlaylist(
|
|||||||
const dumpIds = getCurrentDumpIds(playlist.id);
|
const dumpIds = getCurrentDumpIds(playlist.id);
|
||||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||||
|
|
||||||
if (playlist.isPublic) {
|
// Followers can't open a private dump, even the owner's own.
|
||||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
if (playlist.isPublic && !dump.is_private) {
|
||||||
dumpId,
|
|
||||||
) as { title: string } | undefined;
|
|
||||||
if (dumpRow) {
|
|
||||||
notifyPlaylistFollowersNewDump(
|
notifyPlaylistFollowersNewDump(
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title,
|
||||||
dumpId,
|
dumpId,
|
||||||
dumpRow.title,
|
dump.title,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function removeDumpFromPlaylist(
|
export function removeDumpFromPlaylist(
|
||||||
playlistId: string,
|
playlistId: string,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getDump } from "../dump-service.ts";
|
|||||||
import { getUserByUsername } from "../user-service.ts";
|
import { getUserByUsername } from "../user-service.ts";
|
||||||
import { getPlaylistById } from "../playlist-service.ts";
|
import { getPlaylistById } from "../playlist-service.ts";
|
||||||
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||||
|
import { isSelfUrl } from "../../lib/self-url.ts";
|
||||||
|
|
||||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||||
const USER_RE = /^\/users\/([^/]+)$/;
|
const USER_RE = /^\/users\/([^/]+)$/;
|
||||||
@@ -22,10 +23,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
|
|
||||||
matches(url: string): boolean {
|
matches(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { pathname } = new URL(url);
|
return isSelfUrl(new URL(url));
|
||||||
return DUMP_RE.test(pathname) ||
|
|
||||||
USER_RE.test(pathname) ||
|
|
||||||
PLAYLIST_RE.test(pathname);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { bandcampProvider } from "./providers/bandcamp.ts";
|
|||||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||||
import { selfProvider } from "./providers/self.ts";
|
import { selfProvider } from "./providers/self.ts";
|
||||||
import { genericProvider } from "./providers/generic.ts";
|
import { genericProvider } from "./providers/generic.ts";
|
||||||
|
import {
|
||||||
|
isPrivateHostname,
|
||||||
|
resolvePublicAddress,
|
||||||
|
} from "../lib/public-address.ts";
|
||||||
|
import { isSelfUrl } from "../lib/self-url.ts";
|
||||||
|
|
||||||
export interface RichContentProvider {
|
export interface RichContentProvider {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,23 +39,35 @@ const FETCH_HEADERS = {
|
|||||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
|
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
|
||||||
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
|
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
|
||||||
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
|
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
|
||||||
* Returns null if curl is unavailable or exits non-zero.
|
* Returns null if curl is unavailable or exits non-zero.
|
||||||
|
*
|
||||||
|
* `address` pins the connection to the IP already checked by
|
||||||
|
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
|
||||||
|
* back with its Location so fetchWithTimeout can check the next hop too.
|
||||||
*/
|
*/
|
||||||
async function fetchViaCurl(
|
async function fetchViaCurl(
|
||||||
url: string,
|
url: URL,
|
||||||
|
address: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
const tmpPath = await Deno.makeTempFile();
|
const tmpPath = await Deno.makeTempFile();
|
||||||
|
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||||
|
const pinned = address.includes(":") ? `[${address}]` : address;
|
||||||
try {
|
try {
|
||||||
const { code, stdout } = await new Deno.Command("curl", {
|
const { code, stdout } = await new Deno.Command("curl", {
|
||||||
args: [
|
args: [
|
||||||
"--silent",
|
"--silent",
|
||||||
"--insecure",
|
"--insecure",
|
||||||
"--location",
|
"--proto",
|
||||||
|
"=http,https",
|
||||||
|
"--resolve",
|
||||||
|
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||||
"--max-time",
|
"--max-time",
|
||||||
String(Math.ceil(timeoutMs / 1000)),
|
String(Math.ceil(timeoutMs / 1000)),
|
||||||
"--user-agent",
|
"--user-agent",
|
||||||
@@ -62,8 +79,8 @@ async function fetchViaCurl(
|
|||||||
"--output",
|
"--output",
|
||||||
tmpPath,
|
tmpPath,
|
||||||
"--write-out",
|
"--write-out",
|
||||||
"%{content_type}",
|
"%{http_code}\n%{redirect_url}\n%{content_type}",
|
||||||
url,
|
url.href,
|
||||||
],
|
],
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "null",
|
stderr: "null",
|
||||||
@@ -71,8 +88,18 @@ async function fetchViaCurl(
|
|||||||
|
|
||||||
if (code !== 0) return null;
|
if (code !== 0) return null;
|
||||||
|
|
||||||
const contentType = new TextDecoder().decode(stdout).trim();
|
const [status, location, contentType = ""] = new TextDecoder()
|
||||||
|
.decode(stdout).split("\n").map((l) => l.trim());
|
||||||
|
const statusCode = Number(status);
|
||||||
|
if (!(statusCode >= 200 && statusCode <= 599)) return null;
|
||||||
|
const headers = new Headers();
|
||||||
|
if (location) headers.set("location", location);
|
||||||
|
if (contentType) headers.set("content-type", contentType);
|
||||||
|
|
||||||
const bytes = await Deno.readFile(tmpPath);
|
const bytes = await Deno.readFile(tmpPath);
|
||||||
|
if (!contentType.startsWith("text/")) {
|
||||||
|
return new Response(bytes, { status: statusCode, headers });
|
||||||
|
}
|
||||||
// Decode using the charset declared in the Content-Type header so that
|
// Decode using the charset declared in the Content-Type header so that
|
||||||
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
||||||
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||||
@@ -82,7 +109,8 @@ async function fetchViaCurl(
|
|||||||
} catch {
|
} catch {
|
||||||
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||||
}
|
}
|
||||||
return new Response(bodyText, { headers: { "content-type": "text/html" } });
|
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
|
||||||
|
return new Response(bodyText, { status: statusCode, headers });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,25 +118,20 @@ async function fetchViaCurl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWithTimeout(
|
/** One request, no redirects followed. */
|
||||||
url: string,
|
async function fetchOnce(
|
||||||
timeoutMs = 5000,
|
url: URL,
|
||||||
|
address: string,
|
||||||
|
timeoutMs: number,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
async function attempt(): Promise<Response> {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: FETCH_HEADERS,
|
headers: FETCH_HEADERS,
|
||||||
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await attempt();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -118,10 +141,37 @@ export async function fetchWithTimeout(
|
|||||||
// or an unsupported signature algorithm) and offers no way to override
|
// or an unsupported signature algorithm) and offers no way to override
|
||||||
// verification programmatically. Fall back to a `curl --insecure`
|
// verification programmatically. Fall back to a `curl --insecure`
|
||||||
// subprocess, which uses its own TLS stack.
|
// subprocess, which uses its own TLS stack.
|
||||||
const curlRes = await fetchViaCurl(url, timeoutMs);
|
const curlRes = await fetchViaCurl(url, address, timeoutMs);
|
||||||
if (curlRes) return curlRes;
|
if (curlRes) return curlRes;
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
|
||||||
|
* must resolve to a public address (see resolvePublicAddress), so a link or a
|
||||||
|
* redirect can't point the server at its own network.
|
||||||
|
*/
|
||||||
|
export async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<Response> {
|
||||||
|
let current = new URL(url);
|
||||||
|
for (let hop = 0;; hop++) {
|
||||||
|
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||||
|
throw new TypeError(`Unsupported protocol ${current.protocol}`);
|
||||||
|
}
|
||||||
|
const address = await resolvePublicAddress(current);
|
||||||
|
const res = await fetchOnce(current, address, timeoutMs);
|
||||||
|
const location = res.headers.get("location");
|
||||||
|
if (res.status < 300 || res.status >= 400 || !location) return res;
|
||||||
|
|
||||||
|
await res.body?.cancel();
|
||||||
|
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
|
||||||
|
current = new URL(location, current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,24 +579,14 @@ export function extractLinkHref(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateHost(hostname: string): boolean {
|
|
||||||
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
|
|
||||||
if (hostname === "localhost" || hostname === "::1") return true;
|
|
||||||
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
|
|
||||||
|
|
||||||
export function isValidHttpUrl(raw: string): boolean {
|
export function isValidHttpUrl(raw: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(raw);
|
const u = new URL(raw);
|
||||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||||
// Allow private hosts for self-referential gerbeur URLs — they are
|
// This site's own pages are fine even on a private host (e.g. localhost in
|
||||||
// resolved directly from the DB by selfProvider, no outbound HTTP needed.
|
// development): selfProvider reads them from the DB, no HTTP involved.
|
||||||
if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) {
|
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
|
||||||
return false;
|
return !isPrivateHostname(u) || isSelfUrl(u);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type User,
|
type User,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
||||||
import { disconnectUser } from "./ws-service.ts";
|
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
||||||
|
|
||||||
import { hashPassword } from "../lib/jwt.ts";
|
import { hashPassword } from "../lib/jwt.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
@@ -36,8 +36,11 @@ export async function createUser(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertEmailAvailable(request.email);
|
||||||
|
|
||||||
const passwordHash = await hashPassword(request.password);
|
const passwordHash = await hashPassword(request.password);
|
||||||
|
|
||||||
|
try {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||||
@@ -50,6 +53,18 @@ export async function createUser(
|
|||||||
inviterId,
|
inviterId,
|
||||||
request.email,
|
request.email,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// The check above ran before the hash yielded, so a parallel registration
|
||||||
|
// can still take the name first; the UNIQUE constraint is the real guard.
|
||||||
|
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Username already exists",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -85,6 +100,23 @@ export function getUserByUsername(username: string): User {
|
|||||||
return userRowToApi(userRow);
|
return userRowToApi(userRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password resets are looked up by email, so an address can belong to one
|
||||||
|
* account only — otherwise which account a reset link resets is up to row order.
|
||||||
|
*/
|
||||||
|
function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`,
|
||||||
|
).get(email, exceptUserId ?? "");
|
||||||
|
if (row) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
409,
|
||||||
|
"Email already in use",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserByEmail(email: string): User | null {
|
export function getUserByEmail(email: string): User | null {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`${USER_SELECT} WHERE u.email = ?`,
|
`${USER_SELECT} WHERE u.email = ?`,
|
||||||
@@ -141,6 +173,9 @@ export async function updateUser(
|
|||||||
const user = getUserById(userId);
|
const user = getUserById(userId);
|
||||||
|
|
||||||
const { password, description, email, ...requestFields } = request;
|
const { password, description, email, ...requestFields } = request;
|
||||||
|
if (email !== undefined && email !== user.email) {
|
||||||
|
assertEmailAvailable(email, userId);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updatedUser: User = {
|
const updatedUser: User = {
|
||||||
@@ -170,6 +205,15 @@ export async function updateUser(
|
|||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new password ends every session opened with the old one.
|
||||||
|
if (password) {
|
||||||
|
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||||
|
now.toISOString(),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
closeUserSockets(userId, "Password changed");
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedUser.description) {
|
if (updatedUser.description) {
|
||||||
linkAttachments(updatedUser.description, userId);
|
linkAttachments(updatedUser.description, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,26 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
|||||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||||
|
|
||||||
export function castVote(dumpId: string, userId: string): number {
|
export interface VoteResult {
|
||||||
|
voteCount: number;
|
||||||
|
/** Private dumps' vote counts go to their owner only. */
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility of the dump, or a 404 when the user can't see it — voting must not
|
||||||
|
// confirm that a private dump exists.
|
||||||
|
function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT is_private, user_id FROM dumps WHERE id = ?;`,
|
||||||
|
).get(dumpId) as { is_private: number; user_id: string } | undefined;
|
||||||
|
if (!row || (row.is_private && row.user_id !== userId)) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
|
}
|
||||||
|
return { isPrivate: Boolean(row.is_private) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function castVote(dumpId: string, userId: string): VoteResult {
|
||||||
|
const { isPrivate } = visibleDump(dumpId, userId);
|
||||||
let voteCount: number;
|
let voteCount: number;
|
||||||
try {
|
try {
|
||||||
db.exec("BEGIN;");
|
db.exec("BEGIN;");
|
||||||
@@ -34,10 +53,16 @@ export function castVote(dumpId: string, userId: string): number {
|
|||||||
try {
|
try {
|
||||||
notifyDumpOwnerUpvote(userId, dumpId);
|
notifyDumpOwnerUpvote(userId, dumpId);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return voteCount;
|
return { voteCount, isPrivate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeVote(dumpId: string, userId: string): number {
|
// No visibility check: taking back a vote on a dump that has since gone
|
||||||
|
// private is harmless, and it's the voter's own row.
|
||||||
|
export function removeVote(dumpId: string, userId: string): VoteResult {
|
||||||
|
const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get(
|
||||||
|
dumpId,
|
||||||
|
) as { is_private: number } | undefined;
|
||||||
|
const isPrivate = Boolean(row?.is_private);
|
||||||
try {
|
try {
|
||||||
db.exec("BEGIN;");
|
db.exec("BEGIN;");
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
@@ -50,11 +75,11 @@ export function removeVote(dumpId: string, userId: string): number {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
||||||
).run(dumpId);
|
).run(dumpId);
|
||||||
const row = db.prepare(
|
const count = db.prepare(
|
||||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||||
).get(dumpId) as { vote_count: number } | undefined;
|
).get(dumpId) as { vote_count: number } | undefined;
|
||||||
db.exec("COMMIT;");
|
db.exec("COMMIT;");
|
||||||
return row?.vote_count ?? 0;
|
return { voteCount: count?.vote_count ?? 0, isPrivate };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof APIException) throw err;
|
if (err instanceof APIException) throw err;
|
||||||
db.exec("ROLLBACK;");
|
db.exec("ROLLBACK;");
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ export function disconnectUser(userId: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the user's sockets without logging anyone out: each reconnects with
|
||||||
|
* whatever token it holds, and /ws turns a revoked one into a force_logout.
|
||||||
|
* Used on a password change, where the tab that made the change has already
|
||||||
|
* swapped in a fresh token and must stay signed in.
|
||||||
|
*/
|
||||||
|
export function closeUserSockets(userId: string, reason: string): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId === userId) client.socket.close(1000, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function broadcastPresence(): void {
|
export function broadcastPresence(): void {
|
||||||
const users = getOnlineUsers();
|
const users = getOnlineUsers();
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
@@ -108,13 +120,16 @@ export function broadcastDumpDeleted(dumpId: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
|
||||||
export function broadcastVoteUpdate(
|
export function broadcastVoteUpdate(
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
voteCount: number,
|
voteCount: number,
|
||||||
voterId: string,
|
voterId: string,
|
||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
|
onlyUserId?: string,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||||
send(client.socket, {
|
send(client.socket, {
|
||||||
type: "votes_update",
|
type: "votes_update",
|
||||||
dumpId,
|
dumpId,
|
||||||
@@ -125,13 +140,16 @@ export function broadcastVoteUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
|
||||||
export function broadcastCommentLikeUpdate(
|
export function broadcastCommentLikeUpdate(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
likeCount: number,
|
likeCount: number,
|
||||||
likerId: string,
|
likerId: string,
|
||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
|
onlyUserId?: string,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||||
send(client.socket, {
|
send(client.socket, {
|
||||||
type: "comment_likes_update",
|
type: "comment_likes_update",
|
||||||
commentId,
|
commentId,
|
||||||
@@ -157,12 +175,17 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
|
|||||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
// `wasPublic`: the visibility before this change. A playlist that just went
|
||||||
// Broadcast to ALL clients so non-owners can react to visibility changes
|
// private still goes to everyone, so non-owners can drop it from their views;
|
||||||
// (e.g. remove a now-private playlist from their feed).
|
// one that was and stays private goes to its owner alone.
|
||||||
for (const client of clients) {
|
export function broadcastPlaylistUpdated(
|
||||||
send(client.socket, { type: "playlist_updated", playlist });
|
playlist: Playlist,
|
||||||
}
|
wasPublic: boolean,
|
||||||
|
): void {
|
||||||
|
sendToPlaylistAudience(
|
||||||
|
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
|
||||||
|
{ type: "playlist_updated", playlist },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPlaylistDeleted(
|
export function broadcastPlaylistDeleted(
|
||||||
@@ -305,44 +328,7 @@ export function isUserChatOpen(userId: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket,
|
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||||
// so opening more tabs doesn't buy more throughput.
|
|
||||||
interface Bucket {
|
|
||||||
tokens: number;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
const buckets = new Map<string, Bucket>();
|
|
||||||
|
|
||||||
export interface RateLimit {
|
|
||||||
/** Burst size. */
|
|
||||||
capacity: number;
|
|
||||||
/** Tokens regained per second. */
|
|
||||||
refill: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Takes one token for `key`; false when the bucket is empty. */
|
|
||||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
|
||||||
bucket.tokens = Math.min(
|
|
||||||
limit.capacity,
|
|
||||||
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
|
|
||||||
);
|
|
||||||
bucket.at = now;
|
|
||||||
buckets.set(key, bucket);
|
|
||||||
if (bucket.tokens < 1) return false;
|
|
||||||
bucket.tokens -= 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full buckets carry no information; drop them so the map tracks only users
|
|
||||||
// who've been active lately.
|
|
||||||
setInterval(() => {
|
|
||||||
const cutoff = Date.now() - 5 * 60_000;
|
|
||||||
for (const [key, bucket] of buckets) {
|
|
||||||
if (bucket.at < cutoff) buckets.delete(key);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
export function handleClientPong(client: WsClient): void {
|
export function handleClientPong(client: WsClient): void {
|
||||||
client.pongReceived = true;
|
client.pongReceived = true;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { deserializeAuthResponse } from "../model.ts";
|
||||||
import { Modal } from "./Modal.tsx";
|
import { Modal } from "./Modal.tsx";
|
||||||
import {
|
import {
|
||||||
expectOk,
|
expectOk,
|
||||||
@@ -25,7 +26,7 @@ interface Values {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch, login } = useAuth();
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const form = useApiForm<Values>({
|
const form = useApiForm<Values>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
@@ -33,13 +34,19 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
|
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
|
||||||
await expectOk(
|
// The change signs out every session opened with the old password, this
|
||||||
|
// one included, so keep the fresh tokens it hands back.
|
||||||
|
const tokens = await expectOk<{ token: string; mediaToken: string }>(
|
||||||
await authFetch(`${API_URL}/api/users/me/change-password`, {
|
await authFetch(`${API_URL}/api/users/me/change-password`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const storedRaw = localStorage.getItem("authResponse");
|
||||||
|
if (storedRaw) {
|
||||||
|
login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), ...tokens });
|
||||||
|
}
|
||||||
setDone(true);
|
setDone(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function DumpCard(
|
|||||||
DumpCardProps,
|
DumpCardProps,
|
||||||
) {
|
) {
|
||||||
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||||
const { token } = useAuth();
|
const { mediaToken } = useAuth();
|
||||||
const { byId } = useCategories();
|
const { byId } = useCategories();
|
||||||
const dumpCategories = (dump.categoryIds ?? [])
|
const dumpCategories = (dump.categoryIds ?? [])
|
||||||
.map((id) => byId.get(id))
|
.map((id) => byId.get(id))
|
||||||
@@ -59,7 +59,7 @@ export function DumpCard(
|
|||||||
compact
|
compact
|
||||||
dumpHref={dumpUrl(dump)}
|
dumpHref={dumpUrl(dump)}
|
||||||
thumbnailOverrideUrl={dump.thumbnailMime
|
thumbnailOverrideUrl={dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export function DumpCreateModal(
|
|||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
const encoded = encodeURIComponent(canonicalUrl);
|
const encoded = encodeURIComponent(canonicalUrl);
|
||||||
|
|
||||||
fetch(`${API_URL}/api/preview?url=${encoded}`)
|
authFetch(`${API_URL}/api/preview?url=${encoded}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
const data = body.success
|
const data = body.success
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function filePlayerItem(
|
|||||||
function AudioFilePreview(
|
function AudioFilePreview(
|
||||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||||
) {
|
) {
|
||||||
const { token } = useAuth();
|
const { mediaToken: token } = useAuth();
|
||||||
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
||||||
useContext(PlayerContext);
|
useContext(PlayerContext);
|
||||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||||
@@ -174,7 +174,7 @@ export default function FilePreview(
|
|||||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||||
) {
|
) {
|
||||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||||
const { token } = useAuth();
|
const { mediaToken: token } = useAuth();
|
||||||
const fileUrl = dumpFileUrl(dump, token);
|
const fileUrl = dumpFileUrl(dump, token);
|
||||||
const mime = dump.fileMime ?? "";
|
const mime = dump.fileMime ?? "";
|
||||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function JournalCard(
|
|||||||
JournalCardProps,
|
JournalCardProps,
|
||||||
) {
|
) {
|
||||||
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
||||||
const { token } = useAuth();
|
const { mediaToken } = useAuth();
|
||||||
const { playRichContent } = usePlayRichContent();
|
const { playRichContent } = usePlayRichContent();
|
||||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||||
!isDumpVisited(dump.id);
|
!isDumpVisited(dump.id);
|
||||||
@@ -49,12 +49,12 @@ export function JournalCard(
|
|||||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||||
// still here too, rather than degrading to a text card with a 🎬.
|
// still here too, rather than degrading to a text card with a 🎬.
|
||||||
const thumbnailUrl = dump.thumbnailMime
|
const thumbnailUrl = dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: dump.kind === "file"
|
: dump.kind === "file"
|
||||||
? (dump.fileMime?.startsWith("image/")
|
? (dump.fileMime?.startsWith("image/")
|
||||||
? dumpFileUrl(dump, token)
|
? dumpFileUrl(dump, mediaToken)
|
||||||
: dump.fileMime?.startsWith("video/")
|
: dump.fileMime?.startsWith("video/")
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: null)
|
: null)
|
||||||
: (dump.richContent?.thumbnailUrl ?? null);
|
: (dump.richContent?.thumbnailUrl ?? null);
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// Sessions from before media tokens existed have none, and would keep putting
|
||||||
|
// the full session token in file and thumbnail URLs; fetch one once.
|
||||||
|
const needsMediaToken = !!token && !authResponse?.mediaToken;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !needsMediaToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`${API_URL}/api/users/me/media-token`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
.then((res) => res.ok ? res.json() : null)
|
||||||
|
.then((json) => {
|
||||||
|
const mediaToken = json?.data?.mediaToken;
|
||||||
|
if (cancelled || typeof mediaToken !== "string") return;
|
||||||
|
setAuthResponse((prev) => {
|
||||||
|
if (!prev || prev.token !== token) return prev;
|
||||||
|
const updated = { ...prev, mediaToken };
|
||||||
|
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 };
|
const value: AuthContextValue = { authResponse, setAuthResponse };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export const useAuth = () => {
|
|||||||
return {
|
return {
|
||||||
user: authResponse?.user ?? null,
|
user: authResponse?.user ?? null,
|
||||||
token: authResponse?.token ?? null,
|
token: authResponse?.token ?? null,
|
||||||
|
// For file and thumbnail URLs, where the token is visible in the query
|
||||||
|
// string. Sessions from before media tokens fall back to the session token
|
||||||
|
// until AuthProvider fetches one.
|
||||||
|
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
authFetch,
|
authFetch,
|
||||||
@@ -124,12 +128,13 @@ export const useAuth = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useRequiredAuth = () => {
|
export const useRequiredAuth = () => {
|
||||||
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
const { user, token, mediaToken, login, logout, authFetch, authUpload } =
|
||||||
|
useAuth();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invariant: useRequiredAuth called outside a protected route",
|
"Invariant: useRequiredAuth called outside a protected route",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { user, token, login, logout, authFetch, authUpload };
|
return { user, token, mediaToken, login, logout, authFetch, authUpload };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export function deserializeInviteTreeEntry(
|
|||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
/** Read-only token for media URLs (`?token=`); absent on older sessions. */
|
||||||
|
mediaToken?: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function Dump() {
|
|||||||
const [titleSaving, setTitleSaving] = useState(false);
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
const [titleError, setTitleError] = useState<string | null>(null);
|
const [titleError, setTitleError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { user, token, authFetch } = useAuth();
|
const { user, token, mediaToken, authFetch } = useAuth();
|
||||||
const { byId } = useCategories();
|
const { byId } = useCategories();
|
||||||
const {
|
const {
|
||||||
voteCounts,
|
voteCounts,
|
||||||
@@ -497,7 +497,7 @@ export function Dump() {
|
|||||||
richContent={dump.richContent}
|
richContent={dump.richContent}
|
||||||
dumpHref={dumpUrl(dump)}
|
dumpHref={dumpUrl(dump)}
|
||||||
thumbnailOverrideUrl={dump.thumbnailMime
|
thumbnailOverrideUrl={dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type DumpEditState =
|
|||||||
export function DumpEdit() {
|
export function DumpEdit() {
|
||||||
const { selectedDump } = useParams();
|
const { selectedDump } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { authFetch, token } = useRequiredAuth();
|
const { authFetch, token, mediaToken } = useRequiredAuth();
|
||||||
|
|
||||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||||
|
|
||||||
@@ -195,12 +195,12 @@ export function DumpEdit() {
|
|||||||
const { dump } = state;
|
const { dump } = state;
|
||||||
|
|
||||||
const currentThumbnailSrc = dump.thumbnailMime
|
const currentThumbnailSrc = dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: dump.kind === "file"
|
: dump.kind === "file"
|
||||||
? (dump.fileMime?.startsWith("image/")
|
? (dump.fileMime?.startsWith("image/")
|
||||||
? dumpFileUrl(dump, token)
|
? dumpFileUrl(dump, mediaToken)
|
||||||
: dump.fileMime?.startsWith("video/")
|
: dump.fileMime?.startsWith("video/")
|
||||||
? dumpThumbnailUrl(dump, token)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: null)
|
: null)
|
||||||
: dump.richContent?.thumbnailUrl ?? null;
|
: dump.richContent?.thumbnailUrl ?? null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user