diff --git a/README.md b/README.md index 2cc79e41..84a31552 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,9 @@ api/ validators/ # Per domain: request body guards lib/ # Helpers with no SQL and no outbound fetch: JWT, slugs, files, … db/ - connection.ts # The database handle and transaction() + connection.ts # The database handle and transaction(); creates a new database startup.ts # Housekeeping run when the database opens schema.sql # Database schema - init.ts # First-run database initialisation migrate.ts # Migration runner migrations/ # Versioned schema migrations test/ # Backend tests (Deno): test//.test.ts diff --git a/api/config.ts b/api/config.ts index bca6f373..22b8d675 100644 --- a/api/config.ts +++ b/api/config.ts @@ -56,11 +56,13 @@ export const PUBLIC_URL: string = `http://localhost:${PORT}`; /** * The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't - * {@link PUBLIC_URL}, as with a separate Vite server. It is an allowed origin, - * and password reset links point to it. + * {@link PUBLIC_URL}, as with a separate Vite server, without a trailing slash. + * It is an allowed origin, and password reset links and the Open Graph tags' + * page URLs point to it. */ export const FRONTEND_URL: string = - Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL; + Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") || + PUBLIC_URL; /** * The SQLite database file (`GERBEUR_DB_PATH`). api/test/setup.ts points it at * a throwaway file before anything opens the database. @@ -93,17 +95,10 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; /** - * The largest image upload accepted, in bytes. lib/upload.ts's error message - * says "5 MB", so the two change together. + * The largest image upload accepted, in bytes, for avatars, covers, thumbnails + * and attachments. lib/upload.ts states it in megabytes in its error. */ export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; -/** The image types accepted for avatars, covers, thumbnails and attachments. */ -export const ALLOWED_IMAGE_MIMES: Set = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]); /** * The MIME type prefixes a file dump may have. dump-service also accepts the diff --git a/api/db/connection.ts b/api/db/connection.ts index 3f893f27..c14d81e3 100644 --- a/api/db/connection.ts +++ b/api/db/connection.ts @@ -1,7 +1,8 @@ /** - * Opens the database at {@link DB_PATH} when first imported, applies the - * pending migrations and runs the startup tasks before any importer runs. Only - * the services import it. + * Opens the database at {@link DB_PATH} when first imported, creating it from + * schema.sql if the file doesn't exist, then applies the pending migrations + * and runs the startup tasks before any importer runs. Only the services + * import it. * * @module */ @@ -11,11 +12,20 @@ import { DB_PATH } from "../config.ts"; import { runMigrations } from "./migrate.ts"; import { runStartupTasks } from "./startup.ts"; +const isNew = await Deno.stat(DB_PATH).then(() => false, () => true); + /** * The server's only connection. Its queries are synchronous, so each one * blocks the event loop while it runs. */ export const db: DatabaseSync = new DatabaseSync(DB_PATH); +// schema.sql holds every migration's change, so on a new database each +// migration below finds its work done and is only recorded. +if (isNew) { + db.exec( + await Deno.readTextFile(new URL("./schema.sql", import.meta.url)), + ); +} // WAL lets readers run alongside a writer, and with synchronous = NORMAL a // commit doesn't wait on an fsync: a power loss can lose the last commits but // never corrupts the file. busy_timeout makes another connection to the file diff --git a/api/db/init.ts b/api/db/init.ts deleted file mode 100644 index 295facec..00000000 --- a/api/db/init.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet, - * and leaves an existing one alone. `deno task start` runs it before the - * server, which then runs every migration on the new file: schema.sql already - * holds their changes, so each one finds nothing to do. - * - * @module - */ - -import { DatabaseSync } from "node:sqlite"; -import { DB_PATH } from "../config.ts"; - -try { - await Deno.stat(DB_PATH); - console.log("Database already exists, skipping initialization."); -} catch { - console.log("Initializing database from schema..."); - const schema = Deno.readTextFileSync("api/db/schema.sql"); - const db = new DatabaseSync(DB_PATH); - db.exec(schema); - db.close(); - console.log("Database initialized."); -} diff --git a/api/db/migrate.ts b/api/db/migrate.ts index cf66c3f9..23d8043d 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -23,6 +23,8 @@ import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonic import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts"; import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts"; import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts"; +import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts"; +import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts"; interface Migration { name: string; @@ -32,8 +34,8 @@ interface Migration { /** * Every migration, in the order they run. A new one goes at the end, along * with the same change to schema.sql, and a shipped one is never edited or - * moved. A database made by init.ts records none of them, so each `up` must - * find its change already made and leave it be. + * moved. A database connection.ts made from schema.sql records none of them, + * so each `up` must find its change already made and leave it be. */ const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, @@ -55,6 +57,11 @@ const MIGRATIONS: Migration[] = [ { name: "0014_chat_read_state", up: up0014ChatReadState }, { name: "0015_token_revocation", up: up0015TokenRevocation }, { name: "0016_performance_indexes", up: up0016PerformanceIndexes }, + { name: "0017_case_insensitive_users", up: up0017CaseInsensitiveUsers }, + { + name: "0018_attachment_resource_type", + up: up0018AttachmentResourceType, + }, ]; /** diff --git a/api/db/migrations/0017_case_insensitive_users.ts b/api/db/migrations/0017_case_insensitive_users.ts new file mode 100644 index 00000000..3d352f24 --- /dev/null +++ b/api/db/migrations/0017_case_insensitive_users.ts @@ -0,0 +1,36 @@ +/** + * Makes usernames and emails unique regardless of case. Usernames were unique + * only as typed, so "Bob" and "bob" could both sign up, and a mention, which + * looks names up in lower case, then reached one of them at random. Emails + * had no constraint at all: two sign-ups racing past the service's check could + * share an address, and password resets are looked up by address. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; + +/** + * Run once by `runMigrations`. It refuses to run, naming the clashes, when two + * accounts already share a name or an address in some case: which one keeps + * it is for whoever runs the site to decide, by editing one of them. + */ +export function up(db: DatabaseSync): void { + for (const column of ["username", "email"]) { + const clashes = db.prepare( + `SELECT lower(${column}) AS value FROM users GROUP BY lower(${column}) HAVING COUNT(*) > 1;`, + ).all() as { value: string }[]; + if (clashes.length > 0) { + throw new Error( + `Several accounts share a ${column} in different cases: ${ + clashes.map((c) => c.value).join(", ") + }. Change all but one of each, then restart.`, + ); + } + } + db.exec(` + DROP INDEX IF EXISTS idx_users_username_lower; + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_lower ON users(lower(username)); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users(lower(email)); + `); +} diff --git a/api/db/migrations/0018_attachment_resource_type.ts b/api/db/migrations/0018_attachment_resource_type.ts new file mode 100644 index 00000000..e66dabc4 --- /dev/null +++ b/api/db/migrations/0018_attachment_resource_type.ts @@ -0,0 +1,34 @@ +/** + * Adds `attachments.resource_type`, the kind of thing an attachment's + * `resource_id` names. Without it, an attachment couldn't tell whose it was: + * one pasted into a private dump was served to anyone with its URL, and one + * whose dump, comment or message was deleted was kept for good. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; + +/** + * Run once by `runMigrations`. Existing bound attachments get the type of the + * table that holds their resource; one whose resource is gone gets none, and + * the startup purge deletes it. A database made from schema.sql already has + * the column. + */ +export function up(db: DatabaseSync): void { + const cols = db.prepare(`PRAGMA table_info(attachments);`).all() as { + name: string; + }[]; + if (cols.some((c) => c.name === "resource_type")) return; + db.exec(` + ALTER TABLE attachments ADD COLUMN resource_type TEXT; + UPDATE attachments SET resource_type = CASE + WHEN resource_id IN (SELECT id FROM dumps) THEN 'dump' + WHEN resource_id IN (SELECT id FROM comments) THEN 'comment' + WHEN resource_id IN (SELECT id FROM playlists) THEN 'playlist' + WHEN resource_id IN (SELECT id FROM chat_messages) THEN 'chat' + WHEN resource_id IN (SELECT id FROM users) THEN 'user' + END + WHERE resource_id IS NOT NULL; + `); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 36c1c79d..d56454d0 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -175,13 +175,15 @@ CREATE TABLE invites ( CREATE INDEX idx_invites_inviter ON invites(inviter_id); CREATE INDEX idx_users_invited_by ON users(invited_by); -CREATE INDEX idx_users_username_lower ON users(lower(username)); +CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username)); +CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email)); CREATE TABLE attachments ( id TEXT PRIMARY KEY, resource_id TEXT, mime TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + resource_type TEXT ); CREATE INDEX idx_attachments_resource ON attachments(resource_id); diff --git a/api/db/startup.ts b/api/db/startup.ts index b1e69011..a6347297 100644 --- a/api/db/startup.ts +++ b/api/db/startup.ts @@ -5,41 +5,61 @@ * @module */ -import { randomBytes, scryptSync } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; import { ATTACHMENTS_DIR, ORPHANED_ATTACHMENTS_RETENTION_HOURS, UNUSED_INVITES_RETENTION_DAYS, } from "../config.ts"; +import { hashPassword } from "../lib/password.ts"; + +/** Returns the time `ms` ago as an ISO string, the format dates are stored in. */ +function isoAgo(ms: number): string { + return new Date(Date.now() - ms).toISOString(); +} /** * Runs after the migrations, each time the server starts. It deletes used or - * expired reset tokens, invites past their validity and attachments nothing - * links to. On an instance with no users, it creates the user `admin` with the + * expired reset tokens, invites past their validity, and attachments nothing + * links to or whose resource is gone. On an instance with no users, it creates the user `admin` with the * password `admin`, which whoever installs it must change. */ export async function runStartupTasks(db: DatabaseSync): Promise { + // Dates are stored as ISO strings and compared as text, so the cutoffs are + // ISO strings too. SQLite's datetime() has a space where ISO has a `T`. db.prepare( - `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, - ).run(); + `DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at IS NOT NULL;`, + ).run(isoAgo(0)); db.prepare( - `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, - ).run(); + `DELETE FROM invites WHERE used_at IS NULL AND created_at < ?;`, + ).run(isoAgo(UNUSED_INVITES_RETENTION_DAYS * 86_400_000)); // An image is uploaded as soon as it's pasted, and linked only when the text - // it's pasted into is saved, so a fresh one is left alone. + // it's pasted into is saved, so a fresh unlinked one is left alone. One whose + // dump, comment, playlist, message or user is gone goes whatever its age. + const attachmentCutoff = isoAgo( + ORPHANED_ATTACHMENTS_RETENTION_HOURS * 3_600_000, + ); + const ORPHANED = ` + (resource_id IS NULL AND created_at < ?) + OR (resource_id IS NOT NULL AND NOT CASE resource_type + WHEN 'dump' THEN EXISTS (SELECT 1 FROM dumps WHERE id = resource_id) + WHEN 'comment' THEN EXISTS (SELECT 1 FROM comments WHERE id = resource_id AND deleted = 0) + WHEN 'playlist' THEN EXISTS (SELECT 1 FROM playlists WHERE id = resource_id) + WHEN 'chat' THEN EXISTS (SELECT 1 FROM chat_messages WHERE id = resource_id) + WHEN 'user' THEN EXISTS (SELECT 1 FROM users WHERE id = resource_id) + ELSE 0 + END)`; const orphanedAttachments = db.prepare( - `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, - ).all() as { id: string }[]; + `SELECT id FROM attachments WHERE ${ORPHANED};`, + ).all(attachmentCutoff) as { id: string }[]; if (orphanedAttachments.length > 0) { for (const { id } of orphanedAttachments) { await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); } - db.prepare( - `DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, - ).run(); + db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`) + .run(attachmentCutoff); } // Registration needs an invite, so the first user can only come from here. @@ -47,13 +67,9 @@ export async function runStartupTasks(db: DatabaseSync): Promise { count: number; }; if (userCount.count === 0) { - // The format of hashPassword in lib/jwt.ts, which verifies it. - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync("admin", salt, 64).toString("hex"); - const passwordHash = `${hash}.${salt}`; db.prepare( - `INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`, - ).run(crypto.randomUUID(), passwordHash); + `INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', ?, 'admin@localhost')`, + ).run(crypto.randomUUID(), await hashPassword("admin"), isoAgo(0)); console.log( "Created default admin user (username: admin, password: admin)", ); diff --git a/api/lib/api-exception.ts b/api/lib/api-exception.ts index 9faaccab..9f9cae9f 100644 --- a/api/lib/api-exception.ts +++ b/api/lib/api-exception.ts @@ -5,24 +5,36 @@ * @module */ -import type { APIErrorCode } from "../../shared/api.ts"; +import { APIErrorCode } from "../../shared/api.ts"; + +/** Passed to an {@link APIException}; any other status is a type error. */ +export type APIStatus = 400 | 401 | 403 | 404 | 409 | 429 | 500 | 502; + +const CODES: Record = { + 400: APIErrorCode.BAD_REQUEST, + 401: APIErrorCode.UNAUTHORIZED, + 403: APIErrorCode.FORBIDDEN, + 404: APIErrorCode.NOT_FOUND, + 409: APIErrorCode.CONFLICT, + 429: APIErrorCode.RATE_LIMITED, + 500: APIErrorCode.SERVER_ERROR, + 502: APIErrorCode.BAD_GATEWAY, +}; /** * Thrown by a route, a service or a remote fetch to answer the request with an * error. The error middleware sends it as an `APIFailure`, message included, - * so the message must be fit for the user. + * so the message must be fit for the user. Use 401 for a caller who isn't + * signed in, 403 for one who is but may not, and 404 rather than 403 when the + * caller mustn't learn that the thing exists. */ export class APIException extends Error { readonly code: APIErrorCode; - readonly status: number; + readonly status: APIStatus; - /** - * Creates the exception. A code goes with several statuses, as - * `UNAUTHORIZED` does with 401 and 403, so both are given. - */ - constructor(code: APIErrorCode, status: number, message: string) { + constructor(status: APIStatus, message: string) { super(message); - this.code = code; this.status = status; + this.code = CODES[status]; } } diff --git a/api/lib/auth.ts b/api/lib/auth.ts index 091813a2..623b2afe 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -8,7 +8,6 @@ import type { Context } from "@oak/oak"; import { verifyJWT, verifyMediaToken } from "./jwt.ts"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "./api-exception.ts"; import type { AuthPayload } from "../model/auth.ts"; import type { Role } from "../../shared/user.ts"; @@ -82,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise { const payload = await authenticate(token); if (!payload) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); + throw new APIException(401, "Invalid token"); } return payload.userId; } diff --git a/api/lib/canonical-url.ts b/api/lib/canonical-url.ts index 12770475..ed56840b 100644 --- a/api/lib/canonical-url.ts +++ b/api/lib/canonical-url.ts @@ -37,7 +37,7 @@ function isTrackingParam(key: string): boolean { return k.startsWith("utm_") || TRACKING_PARAMS.has(k); } -/** YouTube's hosts, without the "www." {@link canonicalizeUrl} strips first. */ +/** YouTube's hosts, without their "www.". */ const YOUTUBE_HOSTS = new Set([ "youtube.com", "m.youtube.com", @@ -45,13 +45,24 @@ const YOUTUBE_HOSTS = new Set([ "youtube-nocookie.com", ]); +/** + * Returns `u`'s host in lower case and without "www.", when it is one of + * YouTube's (youtu.be aside), and null otherwise. The YouTube preview provider + * uses it too, so both agree on what a YouTube link is. + */ +export function youtubeHost(u: URL): string | null { + const host = u.hostname.toLowerCase().replace(/^www\./, ""); + return YOUTUBE_HOSTS.has(host) ? host : null; +} + /** * Returns the id of the video a YouTube URL points at, in any of the shapes * people paste (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, - * `/live/ID`). The timestamp is dropped: the same video linked at 2:30 is still - * a repost. + * `/live/ID`), and null for any other URL. `host` is lower case, without + * "www.". The canonical key drops the timestamp, since the same video linked + * at 2:30 is still a repost; the YouTube provider reads it separately. */ -function youtubeVideoId( +export function youtubeVideoId( host: string, pathname: string, params: URLSearchParams, diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 46935067..b89a7509 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,16 +1,14 @@ /** * Signs and verifies the server's three kinds of token (session, media and - * password reset), all HS256 with the one key, and hashes passwords. Only a - * token's signature and expiry are checked here: `lib/auth.ts` checks a session - * or media token against its user. + * password reset), all HS256 with the one key. Only a token's signature and + * expiry are checked here: `lib/auth.ts` checks a session or media token + * against its user. * * Importing this module throws when `GERBEUR_JWT_SECRET` is unset. * * @module */ -import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; -import { Buffer } from "node:buffer"; import { jwtVerify, SignJWT } from "@panva/jose"; import { @@ -130,41 +128,3 @@ export async function verifyJWT(token: string): Promise { return null; } } - -/** - * Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for - * `users.password_hash`. - */ -export function hashPassword(password: string): Promise { - const salt = randomBytes(16).toString("hex"); - - return new Promise((resolve, reject) => { - scrypt(password, salt, 64, (err, derivedKey) => { - if (err) reject(err); - else resolve(`${derivedKey.toString("hex")}.${salt}`); - }); - }); -} - -/** Checks `password` against a hash made by {@link hashPassword}. */ -export function verifyPassword( - password: string, - storedHash: string, -): Promise { - const [hash, salt] = storedHash.split("."); - // scrypt would throw on a missing salt, and a malformed hash matches nothing. - if (!hash || !salt) return Promise.resolve(false); - - return new Promise((resolve, reject) => { - scrypt(password, salt, 64, (err, derivedKey) => { - if (err) reject(err); - else { - const expected = Buffer.from(hash ?? "", "hex"); - resolve( - expected.length === derivedKey.length && - timingSafeEqual(expected, derivedKey), - ); - } - }); - }); -} diff --git a/api/lib/password.ts b/api/lib/password.ts new file mode 100644 index 00000000..42e0677c --- /dev/null +++ b/api/lib/password.ts @@ -0,0 +1,48 @@ +/** + * Hashes and checks passwords, for sign-up, sign-in, password changes and the + * admin that `db/startup.ts` seeds. It reads no config, so the startup tasks + * can use it before the server's settings are checked. + * + * @module + */ + +import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; +import { Buffer } from "node:buffer"; + +/** + * Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for + * `users.password_hash`. + */ +export function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString("hex"); + + return new Promise((resolve, reject) => { + scrypt(password, salt, 64, (err, derivedKey) => { + if (err) reject(err); + else resolve(`${derivedKey.toString("hex")}.${salt}`); + }); + }); +} + +/** Checks `password` against a hash made by {@link hashPassword}. */ +export function verifyPassword( + password: string, + storedHash: string, +): Promise { + const [hash, salt] = storedHash.split("."); + // scrypt would throw on a missing salt, and a malformed hash matches nothing. + if (!hash || !salt) return Promise.resolve(false); + + return new Promise((resolve, reject) => { + scrypt(password, salt, 64, (err, derivedKey) => { + if (err) reject(err); + else { + const expected = Buffer.from(hash ?? "", "hex"); + resolve( + expected.length === derivedKey.length && + timingSafeEqual(expected, derivedKey), + ); + } + }); + }); +} diff --git a/api/lib/static.ts b/api/lib/static.ts index 8e7222e1..22434200 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -38,7 +38,8 @@ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; export function routeStaticFilesFrom( staticPaths: string[], ): (context: Context>, next: Next) => Promise { - return async (context: Context>, next: Next) => { + // The last middleware: it always answers, so it never calls `next`. + return async (context: Context>, _next: Next) => { const pathname = context.request.url.pathname; if (pathname === "/" || pathname === "/index.html") { @@ -78,6 +79,5 @@ export function routeStaticFilesFrom( // Any other path is a client-side route, opened directly or reloaded. await serveIndexHtml(context, staticPaths[0]); - await next(); }; } diff --git a/api/lib/upload.ts b/api/lib/upload.ts index b8571b56..9420e5eb 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -6,31 +6,15 @@ */ import type { Context } from "@oak/oak"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "./api-exception.ts"; import { serveFile } from "./serve-file.ts"; -import { - ALLOWED_IMAGE_MIMES, - ATTACHMENTS_DIR, - AVATARS_DIR, - DUMPS_DIR, - MAX_IMAGE_SIZE_BYTES, - PLAYLIST_IMAGES_DIR, - UPLOADS_DIR, -} from "../config.ts"; - -export { - ALLOWED_IMAGE_MIMES, - ATTACHMENTS_DIR, - AVATARS_DIR, - DUMPS_DIR, - PLAYLIST_IMAGES_DIR, - UPLOADS_DIR, -}; +import { dirname } from "node:path"; +import { MAX_IMAGE_SIZE_BYTES } from "../config.ts"; /** * Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic - * bytes, or null for anything else. The type the browser declared is never + * bytes, or null for anything else. These four are the only image types + * accepted anywhere. The type the browser declared is never * trusted, since the file is later served with the type found here. */ export function detectImageMime(data: Uint8Array): string | null { @@ -63,15 +47,13 @@ export function detectImageMime(data: Uint8Array): string | null { export function validateImageUpload(data: Uint8Array): string { if (data.length > MAX_IMAGE_SIZE_BYTES) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, - "File too large (max 5 MB)", + `File too large (max ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)} MB)`, ); } const mime = detectImageMime(data); if (!mime) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, "File content is not a recognised image (JPEG, PNG, GIF, WebP)", ); @@ -104,3 +86,24 @@ export async function serveUploadedFile( if (!served) ctx.response.status = 404; return served; } + +/** + * Writes `data` to `filePath`, replacing whatever is there only once the whole + * file is written, so a failed write leaves the old file whole. The upload + * routes call it before recording the new file, so that clients told of it + * never fetch it early. + */ +export async function replaceUploadedFile( + filePath: string, + data: Uint8Array, +): Promise { + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + await Deno.mkdir(dirname(filePath), { recursive: true }); + try { + await Deno.writeFile(tmpPath, data); + await Deno.rename(tmpPath, filePath); + } catch (err) { + await Deno.remove(tmpPath).catch(() => {}); + throw err; + } +} diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 61ee8c03..945c2545 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -13,7 +13,6 @@ import { type State, } from "@oak/oak"; import { authenticate } from "../lib/auth.ts"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { AuthPayload } from "../model/auth.ts"; import { can } from "../lib/permissions.ts"; @@ -49,18 +48,14 @@ export async function authMiddleware( const authHeader = ctx.request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Missing or invalid token", - ); + throw new APIException(401, "Missing or invalid token"); } const token = authHeader.substring(7); const payload = await authenticate(token); if (!payload) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); + throw new APIException(401, "Invalid token"); } ctx.state.user = payload; @@ -79,11 +74,7 @@ export async function adminOnlyMiddleware( next: Next, ): Promise { if (ctx.state.user.role !== "admin") { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Admin access required", - ); + throw new APIException(403, "Admin access required"); } await next(); @@ -104,11 +95,7 @@ export function requirePermission( next: Next, ): Promise => { if (!ctx.state.user || !can(ctx.state.user, permission)) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Insufficient permissions", - ); + throw new APIException(403, "Insufficient permissions"); } await next(); }; diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 9906456f..c561ef18 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -11,7 +11,7 @@ import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; -import { OG_SITE_NAME } from "../config.ts"; +import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts"; import { loadIndexHtml } from "../lib/index-html.ts"; interface OGMeta { @@ -40,7 +40,7 @@ function buildTags(meta: OGMeta): string { : "summary"; const tags = [ `${escapeAttr(meta.title)}`, - ``, + ``, ``, ``, ``, @@ -91,7 +91,9 @@ const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; /** * Serves `index.html` with the tags of the dump, user or public playlist a * path names, and passes the request on when it names none that exists or - * the visitor may see. The tags' URLs are built on the request's origin. + * the visitor may see. The page's URL is on `FRONTEND_URL` and the images' on + * `PUBLIC_URL`, not on the request's origin, which a reverse proxy may have + * rewritten to an internal host or plain http. */ export async function ogMiddleware( ctx: Context, @@ -103,8 +105,8 @@ export async function ogMiddleware( return await next(); } - const origin = ctx.request.url.origin; - const pageUrl = `${origin}${pathname}`; + const origin = PUBLIC_URL; + const pageUrl = `${FRONTEND_URL}${pathname}`; let meta: OGMeta | null = null; diff --git a/api/remote/bandcamp-streams.ts b/api/remote/bandcamp-streams.ts index b6adf34b..dfc44391 100644 --- a/api/remote/bandcamp-streams.ts +++ b/api/remote/bandcamp-streams.ts @@ -8,7 +8,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts"; import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts"; @@ -73,25 +72,17 @@ export async function resolveBandcamp( { force = false }: { force?: boolean } = {}, ): Promise { if (!isValidHttpUrl(pageUrl)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid URL", - ); + throw new APIException(400, "Invalid URL"); } let hostname: string; try { hostname = new URL(pageUrl).hostname; } catch { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL"); + throw new APIException(400, "Invalid URL"); } if (!BANDCAMP_HOST_RE.test(hostname)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Not a Bandcamp URL", - ); + throw new APIException(400, "Not a Bandcamp URL"); } const key = cacheKey(pageUrl); @@ -104,40 +95,24 @@ export async function resolveBandcamp( try { res = await fetchWithTimeout(pageUrl, 8000); } catch { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 502, - "Could not reach Bandcamp", - ); + throw new APIException(502, "Could not reach Bandcamp"); } // A body left unread keeps its connection open until GC, and this endpoint // is public: every early return cancels it. if (!res.ok) { await res.body?.cancel(); - throw new APIException( - APIErrorCode.SERVER_ERROR, - 502, - `Bandcamp returned ${res.status}`, - ); + throw new APIException(502, `Bandcamp returned ${res.status}`); } if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) { await res.body?.cancel(); - throw new APIException( - APIErrorCode.SERVER_ERROR, - 502, - "Bandcamp did not return a page", - ); + throw new APIException(502, "Bandcamp did not return a page"); } const parsed = parseTralbum(await readTextCapped(res), pageUrl); if (!parsed) { // No data, no audio or nothing streamable yet (a preorder). The client // plays the embed instead. - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "No streamable tracks on this page", - ); + throw new APIException(404, "No streamable tracks on this page"); } writeCache(key, parsed); diff --git a/api/remote/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts index 7f3c2dfb..05b57dc9 100644 --- a/api/remote/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -9,13 +9,11 @@ import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; - -const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; +import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts"; /** - * Claims bandcamp.com and its subdomains, and `bandcamp.com.example.org` too, - * since the pattern isn't anchored at the end. Takes the embed player's URL - * from the page's `og:video`. The native player fetches the page's streams + * Claims bandcamp.com and its subdomains, where artists have their pages. Takes + * the embed player's URL from the page's `og:video`. The native player fetches the page's streams * itself at play time, through `GET /api/bandcamp/tracks`. */ export const bandcampProvider: RichContentProvider = { @@ -23,7 +21,7 @@ export const bandcampProvider: RichContentProvider = { matches(url: string): boolean { try { - return BANDCAMP_REGEX.test(new URL(url).hostname); + return BANDCAMP_HOST_RE.test(new URL(url).hostname); } catch { return false; } diff --git a/api/remote/providers/self.ts b/api/remote/providers/self.ts index ee76d4a7..bf769b33 100644 --- a/api/remote/providers/self.ts +++ b/api/remote/providers/self.ts @@ -11,6 +11,7 @@ import type { RichContentProvider } from "./provider.ts"; import { getDump } from "../../services/dump-service.ts"; import { getUserByUsername } from "../../services/user-service.ts"; import { getPlaylistById } from "../../services/playlist-service.ts"; +import { APIException } from "../../lib/api-exception.ts"; import { PUBLIC_URL, THEME_COLOR } from "../../config.ts"; import { isSelfUrl } from "../../lib/self-url.ts"; @@ -30,8 +31,9 @@ const SELF_BRANDING = { /** * Comes before the generic provider, and claims what `isSelfUrl` accepts. Its - * `fetch` rejects with the services' 404 `APIException` for a missing page or - * a private dump. A private playlist is previewed all the same. + * `fetch` rejects with a 404 `APIException` for a missing page, a private dump + * or a private playlist: the preview is stored in the linking dump, which + * others may see. */ export const selfProvider: RichContentProvider = { name: "self", @@ -85,6 +87,9 @@ export const selfProvider: RichContentProvider = { const playlistMatch = pathname.match(PLAYLIST_RE); if (playlistMatch) { const playlist = getPlaylistById(playlistMatch[1]); + if (!playlist.isPublic) { + throw new APIException(404, "Playlist not found"); + } const thumbnailUrl = playlist.imageMime ? `${PUBLIC_URL}/api/playlists/${playlist.id}/image` : undefined; diff --git a/api/remote/providers/youtube.ts b/api/remote/providers/youtube.ts index 5e4548a5..8780cb28 100644 --- a/api/remote/providers/youtube.ts +++ b/api/remote/providers/youtube.ts @@ -10,40 +10,24 @@ import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; +import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts"; -/** - * Returns the id of the video a URL points at, on youtu.be, youtube.com or - * www.youtube.com only; `m.` and `music.` links go to the generic provider. - */ +/** Returns the id of the video `url` points at, in any shape YouTube uses. */ function extractVideoId(url: string): string | null { try { const u = new URL(url); - if (u.hostname === "youtu.be") { - return u.pathname.slice(1).split("/")[0] || null; - } - if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { - if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) { - return u.searchParams.get("v"); - } - if ( - u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/") - ) { - return u.pathname.split("/")[2] || null; - } - } + const host = u.hostname.toLowerCase().replace(/^www\./, ""); + return youtubeVideoId(host, u.pathname, u.searchParams); } catch { - // invalid URL + return null; } - return null; } -/** - * Returns the `list` parameter of a URL, whatever its host: a link to another - * site with a `list` parameter is taken for a YouTube playlist. - */ +/** Returns the `list` parameter of a YouTube URL, and null for any other. */ function extractPlaylistId(url: string): string | null { try { - return new URL(url).searchParams.get("list"); + const u = new URL(url); + return youtubeHost(u) ? u.searchParams.get("list") : null; } catch { return null; } @@ -73,21 +57,14 @@ function extractStartSeconds(url: string): number | null { } /** - * Returns the path of a youtube.com channel URL (`/channel/UC…`, `/@handle`, + * Returns the path of a YouTube channel URL (`/channel/UC…`, `/@handle`, * `/c/name` or `/user/name`); null for any other URL. */ function extractChannelPath(url: string): string | null { try { const u = new URL(url); - if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { - if ( - u.pathname.startsWith("/channel/") || - u.pathname.startsWith("/@") || - u.pathname.startsWith("/c/") || - u.pathname.startsWith("/user/") - ) { - return u.pathname; - } + if (youtubeHost(u) && /^\/(channel\/|@|c\/|user\/)/.test(u.pathname)) { + return u.pathname; } } catch { // invalid URL diff --git a/api/remote/youtube-playlist.ts b/api/remote/youtube-playlist.ts index 14766bb1..6a763d65 100644 --- a/api/remote/youtube-playlist.ts +++ b/api/remote/youtube-playlist.ts @@ -12,7 +12,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { readTextCapped } from "./fetch.ts"; import type { @@ -164,11 +163,7 @@ export async function resolveYouTubePlaylist( listId: string, ): Promise { if (!LIST_ID_RE.test(listId)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid playlist ID", - ); + throw new APIException(400, "Invalid playlist ID"); } const hit = cache.get(listId); @@ -187,20 +182,12 @@ export async function resolveYouTubePlaylist( ); if (!res.ok) { await res.body?.cancel(); - throw new APIException( - APIErrorCode.SERVER_ERROR, - 502, - `YouTube returned ${res.status}`, - ); + throw new APIException(502, `YouTube returned ${res.status}`); } html = await readTextCapped(res); } catch (err) { if (err instanceof APIException) throw err; - throw new APIException( - APIErrorCode.SERVER_ERROR, - 502, - "Could not reach YouTube", - ); + throw new APIException(502, "Could not reach YouTube"); } finally { clearTimeout(timer); } @@ -208,11 +195,7 @@ export async function resolveYouTubePlaylist( const parsed = parsePlaylistPage(html, listId); if (!parsed) { // The client then shows the embed without a tracklist. - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "No tracks on this playlist", - ); + throw new APIException(404, "No tracks on this playlist"); } if (cache.size >= MAX_ENTRIES) { diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index 743703e6..714115f4 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -4,7 +4,9 @@ * * - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at * most 5 MB) and answers its id. - * - `GET /api/attachments/:attachmentId` — public. Serves the image. + * - `GET /api/attachments/:attachmentId?token=` — public, but an image in a + * private dump or playlist, or in a comment on a private dump, needs its + * owner's media `token`. Serves the image. * * @module */ @@ -13,16 +15,14 @@ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { createAttachment, - getAttachment, + getAttachmentAccess, } from "../services/attachment-service.ts"; -import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { authenticateMedia } from "../lib/auth.ts"; +import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { AttachmentResponse } from "../../shared/dump.ts"; -import { - ATTACHMENTS_DIR, - serveUploadedFile, - validateImageUpload, -} from "../lib/upload.ts"; +import { serveUploadedFile, validateImageUpload } from "../lib/upload.ts"; +import { ATTACHMENTS_DIR } from "../config.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -33,18 +33,14 @@ const router: Router = new Router(); router.post("/api/attachments", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Expected multipart/form-data", - ); + throw new APIException(400, "Expected multipart/form-data"); } const body = await ctx.request.body.formData(); const file = body.get("file"); if (!(file instanceof File)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file"); + throw new APIException(400, "Missing file"); } const data = new Uint8Array(await file.arrayBuffer()); @@ -71,18 +67,30 @@ router.post("/api/attachments", authMiddleware, async (ctx) => { router.get("/api/attachments/:attachmentId", async (ctx) => { const { attachmentId } = ctx.params; - let attachment; + let access; try { - attachment = getAttachment(attachmentId); + access = getAttachmentAccess(attachmentId); } catch { ctx.response.status = 404; return; } + if (access.ownerId) { + // An can't send an Authorization header, so the viewer's media + // token comes in the query string. + const token = ctx.request.url.searchParams.get("token"); + const viewerId = token ? await authenticateMedia(token) : null; + if (viewerId !== access.ownerId) { + ctx.response.status = 404; + return; + } + } await serveUploadedFile( ctx, `${ATTACHMENTS_DIR}/${attachmentId}`, - attachment.mime, + access.mime, + 3600, + !!access.ownerId, ); }); diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index c9a35bd4..878b1cc7 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -19,15 +19,16 @@ import { broadcastUserUpdated, updateClientAvatar, } from "../services/ws-service.ts"; -import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { SelfUser } from "../../shared/user.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts"; import { - AVATARS_DIR, + replaceUploadedFile, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; +import { AVATARS_DIR } from "../config.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -39,33 +40,23 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { const authPayload = ctx.state.user; const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Expected multipart/form-data", - ); + throw new APIException(400, "Expected multipart/form-data"); } const body = await ctx.request.body.formData(); const file = body.get("file"); if (!(file instanceof File)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); + throw new APIException(400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); - const filePath = `${AVATARS_DIR}/${authPayload.userId}`; - await Deno.mkdir(AVATARS_DIR, { recursive: true }); - await Deno.writeFile(filePath, data); - try { - updateUserAvatar(authPayload.userId, mime); - } catch (err) { - // The row doesn't describe the new file, so don't keep it. - await Deno.remove(filePath).catch(() => {}); - throw err; - } + // Written before the record announces it, or clients would fetch it too + // early. + await replaceUploadedFile(`${AVATARS_DIR}/${authPayload.userId}`, data); + updateUserAvatar(authPayload.userId, mime); updateClientAvatar(authPayload.userId, mime); const user = getUserById(authPayload.userId); diff --git a/api/routes/categories.ts b/api/routes/categories.ts index 528ae332..f8c71838 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -16,11 +16,7 @@ import { Router } from "@oak/oak"; -import { - APIErrorCode, - type APIResponse, - type PaginatedData, -} from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Category } from "../../shared/category.ts"; import type { Dump } from "../../shared/dump.ts"; @@ -87,11 +83,7 @@ router.post( async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreateCategoryRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid category data", - ); + throw new APIException(400, "Invalid category data"); } const category = createCategory(body); const responseBody: APIResponse = { @@ -110,11 +102,7 @@ router.put( async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateCategoryRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid category data", - ); + throw new APIException(400, "Invalid category data"); } const category = updateCategory(ctx.params.id, body); const responseBody: APIResponse = { diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 2b58b1db..e21f876b 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -18,11 +18,7 @@ */ import { Router } from "@oak/oak"; -import { - APIErrorCode, - type APIResponse, - type PaginatedData, -} from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Comment } from "../../shared/comment.ts"; import { @@ -72,11 +68,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { const dump = getDump(ctx.params.dumpId, userId); const body: unknown = await ctx.request.body.json(); if (!isCreateCommentRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid comment data", - ); + throw new APIException(400, "Invalid comment data"); } const comment = createComment( dump.id, @@ -95,11 +87,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => { const canModerate = can(ctx.state.user, "comment:moderate"); const body: unknown = await ctx.request.body.json(); if (!isUpdateCommentRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid comment data", - ); + throw new APIException(400, "Invalid comment data"); } const { comment, isPrivate } = updateComment( ctx.params.commentId, diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index f6255c1b..b2f53a17 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -37,11 +37,7 @@ import { Router } from "@oak/oak"; -import { - APIErrorCode, - type APIResponse, - type PaginatedData, -} from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Dump, DumpUrlMatch } from "../../shared/dump.ts"; import { @@ -56,7 +52,7 @@ import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; -import { validateImageUpload } from "../lib/upload.ts"; +import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; import { THUMBNAILS_DIR } from "../config.ts"; import { createFileDump, @@ -117,11 +113,7 @@ router.post( const isPrivate = formData.get("isPrivate") === "true"; if (!(file instanceof File)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "A file is required", - ); + throw new APIException(400, "A file is required"); } dump = await createFileDump( @@ -136,11 +128,7 @@ router.post( const body: unknown = await ctx.request.body.json(); if (!isCreateUrlDumpRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid dump data", - ); + throw new APIException(400, "Invalid dump data"); } dump = await createUrlDump(body, userId); @@ -221,11 +209,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to update dump", - ); + throw new APIException(403, "Not authorized to update dump"); } const formData = await ctx.request.body.formData(); @@ -234,11 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const title = formData.get("title"); if (!(file instanceof File)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "A file is required", - ); + throw new APIException(400, "A file is required"); } const updatedDump = await replaceFileDump( @@ -263,21 +243,13 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 422, - "Erroneous user input", - ); + throw new APIException(400, "Erroneous user input"); } const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to update dump", - ); + throw new APIException(403, "Not authorized to update dump"); } const updatedDump = await updateDump(dump.id, body); @@ -291,38 +263,23 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to update dump", - ); + throw new APIException(403, "Not authorized to update dump"); } const formData = await ctx.request.body.formData(); const file = formData.get("file"); if (!(file instanceof File)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Missing file field", - ); + throw new APIException(400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); - // The record is written first. If the file write then fails, the thumbnail - // route finds no custom file and falls back to the generated thumbnail. + // Written before setDumpThumbnail announces it, or clients would fetch it + // too early. + await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data); const updatedDump = setDumpThumbnail(dump.id, mime); - const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`; - await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); - try { - await Deno.writeFile(filePath, data); - } catch (err) { - await Deno.remove(filePath).catch(() => {}); - throw err; - } const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; @@ -334,11 +291,7 @@ router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to update dump", - ); + throw new APIException(403, "Not authorized to update dump"); } const updatedDump = await removeDumpThumbnail(dump.id); @@ -352,11 +305,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to update dump", - ); + throw new APIException(403, "Not authorized to update dump"); } const updatedDump = await refreshDumpMetadata(dump.id); @@ -370,11 +319,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Not authorized to delete dump", - ); + throw new APIException(403, "Not authorized to delete dump"); } await deleteDump(dump.id); diff --git a/api/routes/files.ts b/api/routes/files.ts index 2d2b1cfd..08cd2c20 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -10,9 +10,8 @@ */ import { Router } from "@oak/oak"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; -import { DUMPS_DIR } from "../lib/upload.ts"; +import { DUMPS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; import { serveFile } from "../lib/serve-file.ts"; import { dumpFileVersion, getDump } from "../services/dump-service.ts"; @@ -60,7 +59,7 @@ router.get("/:dumpId", async (ctx) => { // The id names a file on disk: nothing but an id's characters gets through. if (!/^[0-9a-f-]{36}$/.test(dumpId)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); + throw new APIException(400, "Invalid dump ID"); } // Media elements can't send an Authorization header, so the viewer's media @@ -70,11 +69,7 @@ router.get("/:dumpId", async (ctx) => { const dump = getDump(dumpId, viewerId ?? undefined); if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "No file for this dump", - ); + throw new APIException(404, "No file for this dump"); } const path = `${DUMPS_DIR}/${dumpId}`; @@ -111,7 +106,7 @@ router.get("/:dumpId", async (ctx) => { ranges: true, }); if (!served) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); + throw new APIException(404, "File not found"); } }); diff --git a/api/routes/invites.ts b/api/routes/invites.ts index bbbcce3e..a1e97e1b 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -10,7 +10,7 @@ */ import { Router } from "@oak/oak"; -import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { InviteResponse } from "../../shared/user.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; @@ -24,7 +24,7 @@ const router: Router = new Router({ prefix: "/api/invites" }); router.post("/", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); + throw new APIException(401, "Not authenticated"); } const responseBody: APIResponse = { success: true, @@ -41,11 +41,7 @@ router.get("/:token", (ctx) => { const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; } catch { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Invalid or expired invite", - ); + throw new APIException(404, "Invalid or expired invite"); } }); diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 7573fc0d..5f842916 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -13,11 +13,7 @@ */ import { Router } from "@oak/oak"; -import { - APIErrorCode, - type APIResponse, - type PaginatedData, -} from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Notification } from "../../shared/notification.ts"; import { parsePagination } from "../lib/pagination.ts"; @@ -36,7 +32,7 @@ const router: Router = new Router({ prefix: "/api/notifications" }); router.get("/", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); + throw new APIException(401, "Not authenticated"); } const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, hasMore } = getNotificationsForUser( @@ -53,7 +49,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { router.post("/read-all", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); + throw new APIException(401, "Not authenticated"); } markAllRead(ctx.state.user.userId); ctx.response.status = 204; @@ -62,7 +58,7 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => { router.patch("/:id/read", authMiddleware, (ctx) => { const user = ctx.state.user; if (!user) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); + throw new APIException(401, "Not authenticated"); } markOneRead(ctx.params.id, user.userId); ctx.response.status = 204; diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index a5998511..fb6f8454 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -29,7 +29,7 @@ import { Router } from "@oak/oak"; import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts"; -import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isCreatePlaylistRequest, @@ -57,10 +57,11 @@ import { updatePlaylist, } from "../services/playlist-service.ts"; import { - PLAYLIST_IMAGES_DIR, + replaceUploadedFile, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; +import { PLAYLIST_IMAGES_DIR } from "../config.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -84,11 +85,7 @@ router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } const responseBody: APIResponse = { success: true, @@ -110,11 +107,7 @@ router.get("/:playlistId", async (ctx) => { router.put("/:playlistId", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdatePlaylistRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } const playlist = updatePlaylist( ctx.params.playlistId, @@ -158,42 +151,27 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { router.post("/:playlistId/image", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Expected multipart/form-data", - ); + throw new APIException(400, "Expected multipart/form-data"); } const body = await ctx.request.body.formData(); const file = body.get("file"); if (!(file instanceof File)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); + throw new APIException(400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); - // The file is written before setPlaylistImage announces it, or clients would - // fetch the new `?v=` URL too early and cache the old cover under it. It is - // written beside the old one and renamed over it, so a failed write leaves - // the old cover whole. + // Written before setPlaylistImage announces it, or clients would fetch the + // new `?v=` URL too early and cache the old cover under it. const { id } = getEditablePlaylist( ctx.params.playlistId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); - const filePath = `${PLAYLIST_IMAGES_DIR}/${id}`; - const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; - await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); - try { - await Deno.writeFile(tmpPath, data); - await Deno.rename(tmpPath, filePath); - } catch (err) { - await Deno.remove(tmpPath).catch(() => {}); - throw err; - } + await replaceUploadedFile(`${PLAYLIST_IMAGES_DIR}/${id}`, data); const playlist = setPlaylistImage( id, mime, @@ -228,11 +206,7 @@ router.get("/:playlistId/image", async (ctx) => { router.put("/:playlistId/order", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isReorderPlaylistRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } reorderPlaylist( ctx.params.playlistId, diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 3f6192ba..71eadacc 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -34,7 +34,7 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { if (!isValidHttpUrl(url)) { const responseBody: APIFailure = { success: false, - error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" }, + error: { code: APIErrorCode.BAD_REQUEST, message: "Invalid URL" }, }; ctx.response.status = 400; ctx.response.body = responseBody; diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index c1f1e69b..21617b68 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -13,7 +13,6 @@ */ import { Router } from "@oak/oak"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { dumpFileVersion, @@ -175,7 +174,7 @@ router.get("/:dumpId", async (ctx) => { // The id names files on disk: nothing but an id's characters gets through. if (!/^[0-9a-f-]{36}$/.test(dumpId)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); + throw new APIException(400, "Invalid dump ID"); } // Media elements can't send an Authorization header, so the viewer's media @@ -217,11 +216,7 @@ router.get("/:dumpId", async (ctx) => { if ( !await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath)) ) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Could not generate thumbnail", - ); + throw new APIException(404, "Could not generate thumbnail"); } if ( await serveFile(ctx, thumbPath, { @@ -255,11 +250,7 @@ router.get("/:dumpId", async (ctx) => { ) return; } - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "No thumbnail for this dump", - ); + throw new APIException(404, "No thumbnail for this dump"); }); export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index c36d1d9d..c9abf274 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -43,11 +43,7 @@ import { Router } from "@oak/oak"; -import { - APIErrorCode, - type APIResponse, - type PaginatedData, -} from "../../shared/api.ts"; +import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { type AuthResponse, @@ -70,7 +66,8 @@ import { import type { Playlist } from "../../shared/playlist.ts"; import { toPublicUser, toSelfUser, type User } from "../model/user.ts"; -import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts"; +import { createJWT, createMediaToken } from "../lib/jwt.ts"; +import { hashPassword, verifyPassword } from "../lib/password.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { type AuthContext, @@ -107,6 +104,9 @@ import { getFollowedUsersByUser, } from "../services/follow-service.ts"; +// Hashed once, at startup, for the sign-ins of usernames that don't exist. +const UNKNOWN_USER_HASH: Promise = hashPassword(crypto.randomUUID()); + /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. @@ -136,7 +136,6 @@ router.post("/register", async (ctx) => { if (!isRegisterUserRequest(body)) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 400, validateRegisterUserRequest(body) ?? "Invalid request", ); @@ -180,18 +179,13 @@ router.post("/login", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } // Per account, so guessing one user's password is slow however many // connections it comes from. if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 429, "Too many login attempts — try again in a minute", ); @@ -201,21 +195,16 @@ router.post("/login", async (ctx) => { 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", - ); + user = undefined; } - - const valid = await verifyPassword(body.password, user.passwordHash); - if (!valid) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 401, - "Invalid username or password", - ); + // An unknown username gets the same answer as a wrong password, after the + // same work, so neither the message nor the timing tells which exist. + const valid = await verifyPassword( + body.password, + user?.passwordHash ?? await UNKNOWN_USER_HASH, + ); + if (!user || !valid) { + throw new APIException(401, "Invalid username or password"); } const responseBody: APIResponse = { @@ -228,11 +217,7 @@ router.post("/login", async (ctx) => { router.get("/me", authMiddleware, (ctx: AuthContext) => { try { if (!ctx.state.user) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authenticated", - ); + throw new APIException(401, "Not authenticated"); } const responseBody: APIResponse = { @@ -243,11 +228,7 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { } catch (err) { console.error(err); - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Failed to fetch user profile", - ); + throw new APIException(500, "Failed to fetch user profile"); } }); @@ -279,11 +260,7 @@ router.post("/reset-password", async (ctx) => { const token = fieldOf(body, "token"); const newPassword = fieldOf(body, "newPassword"); if (typeof token !== "string" || typeof newPassword !== "string") { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } await resetPassword(token, newPassword); const responseBody: APIResponse = { success: true, data: null }; @@ -296,11 +273,7 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { const newPassword = fieldOf(body, "newPassword"); if (typeof currentPassword !== "string" || typeof newPassword !== "string") { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } if ( @@ -308,7 +281,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { newPassword.length > VALIDATION.PASSWORD_MAX ) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 400, `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, ); @@ -317,11 +289,7 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { const user = getUserById(ctx.state.user.userId); const valid = await verifyPassword(currentPassword, user.passwordHash); if (!valid) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 401, - "Current password is incorrect", - ); + throw new APIException(403, "Current password is incorrect"); } // Changing the password revokes every token issued before it, this @@ -339,11 +307,7 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { router.patch("/me", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + throw new APIException(400, "Invalid request"); } // Only the profile fields: a stolen token alone must not change the role, // the password (which needs the current one) or the username (which is @@ -369,19 +333,11 @@ router.patch( const body: unknown = await ctx.request.body.json(); const role = fieldOf(body, "role"); if (!isRole(role)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid role", - ); + throw new APIException(400, "Invalid role"); } // So that an admin can't demote themselves by mistake and leave no admin. if (ctx.params.userId === ctx.state.user.userId) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Cannot change your own role", - ); + throw new APIException(400, "Cannot change your own role"); } const updated = await updateUser(ctx.params.userId, { role }); const publicUser = toPublicUser(updated); diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 358a3f40..baef7cdc 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -429,8 +429,8 @@ function canModerateChat(client: WsClient): boolean { } /** - * Handles `chat_edit`. It stores the new body, broadcasts it, and notifies the - * users the edit newly @mentions. + * Handles `chat_edit`. It stores the new body, broadcasts it, and notifies, in + * the author's name, the users the edit newly mentions. */ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { const userId = chatWriter(client); @@ -449,10 +449,10 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { ); broadcastChatMessageUpdated(message); // Mentions already notified for this message are deduplicated, so only - // the new ones notify. After a moderator's edit, they come from the - // moderator. + // the new ones notify. A moderator's edit keeps the author's words, so the + // author mentions. notifyMentions( - userId, + message.userId, message.body, "chat", message.id, diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index 463ddd7b..1ff52493 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -7,7 +7,6 @@ */ import { db, transaction } from "../db/connection.ts"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; /** @@ -21,45 +20,112 @@ export function createAttachment(id: string, mime: string): void { ).run(id, mime, new Date().toISOString()); } +/** The kinds of resource whose saved text can link an attachment. */ +export type AttachmentResourceType = + | "dump" + | "comment" + | "playlist" + | "chat" + | "user"; + /** - * Returns attachment `attachmentId`'s MIME type, for - * `GET /api/attachments/:attachmentId` to serve its file with. - * - * @throws {APIException} 404 when there is no such attachment. + * Returned to `GET /api/attachments/:attachmentId` by + * {@link getAttachmentAccess}. `ownerId` is set when only that user may see + * the image, because the resource linking it is private. */ -export function getAttachment(attachmentId: string): { mime: string } { +export interface AttachmentAccess { + mime: string; + ownerId?: string; +} + +/** + * Returns attachment `attachmentId`'s type and who may see it. An attachment + * nothing links yet is public, so the editor can show it before the text is + * saved; its id is unguessable. One linked by a private dump or playlist, or + * by a comment on a private dump, is its owner's. + * + * @throws {APIException} 404 when there is no such attachment, or when the + * resource linking it has been deleted. + */ +export function getAttachmentAccess(attachmentId: string): AttachmentAccess { const row = db.prepare( - "SELECT mime FROM attachments WHERE id = ?", - ).get(attachmentId) as { mime: string } | undefined; + "SELECT mime, resource_type, resource_id FROM attachments WHERE id = ?", + ).get(attachmentId) as + | { mime: string; resource_type: string | null; resource_id: string | null } + | undefined; + if (!row) throw new APIException(404, "Attachment not found"); + if (!row.resource_id) return { mime: row.mime }; - if (!row) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Attachment not found", - ); + const owner = resourceOwner(row.resource_type, row.resource_id); + if (owner === undefined) { + throw new APIException(404, "Attachment not found"); } + return owner ? { mime: row.mime, ownerId: owner } : { mime: row.mime }; +} - return { mime: row.mime }; +/** + * Returns the owner of a private resource, null for a public one, and + * undefined for one that no longer exists. + */ +function resourceOwner( + type: string | null, + id: string, +): string | null | undefined { + switch (type) { + case "dump": { + const dump = db.prepare( + "SELECT user_id, is_private FROM dumps WHERE id = ?", + ).get(id) as { user_id: string; is_private: number } | undefined; + return dump && (dump.is_private ? dump.user_id : null); + } + case "comment": { + const comment = db.prepare( + `SELECT d.user_id, d.is_private FROM comments c + JOIN dumps d ON d.id = c.dump_id WHERE c.id = ? AND c.deleted = 0`, + ).get(id) as { user_id: string; is_private: number } | undefined; + return comment && (comment.is_private ? comment.user_id : null); + } + case "playlist": { + const playlist = db.prepare( + "SELECT user_id, is_public FROM playlists WHERE id = ?", + ).get(id) as { user_id: string; is_public: number } | undefined; + return playlist && (playlist.is_public ? null : playlist.user_id); + } + case "chat": + return db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(id) + ? null + : undefined; + case "user": + return db.prepare("SELECT 1 FROM users WHERE id = ?").get(id) + ? null + : undefined; + default: + return undefined; + } } const ATTACHMENT_RE = /\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; /** - * Binds the attachments whose URLs appear in `text` to `resourceId`, so the - * startup purge keeps them. It is called whenever a dump, comment, chat - * message, playlist or profile description is saved. An attachment already - * bound stays with its resource, so pasting another's URL claims nothing, and - * removing the link from a body never unbinds it. + * Binds the attachments whose URLs appear in `text` to the resource that saved + * it, so the startup purge keeps them as long as that resource exists, and + * they are seen by whoever may see it. It is called whenever a dump, comment, + * chat message, playlist or profile description is saved. An attachment + * already bound stays with its resource, so pasting another's URL claims + * nothing, and removing the link from a body never unbinds it. */ -export function linkAttachments(text: string, resourceId: string): void { +export function linkAttachments( + text: string, + resourceType: AttachmentResourceType, + resourceId: string, +): void { const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); if (ids.length === 0) return; const update = db.prepare( - "UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL", + "UPDATE attachments SET resource_type = ?, resource_id = ? WHERE id = ? AND resource_id IS NULL", ); transaction(() => { - for (const id of ids) update.run(resourceId, id); + for (const id of ids) update.run(resourceType, resourceId, id); }); } diff --git a/api/services/category-service.ts b/api/services/category-service.ts index ffc5f560..6502b1ec 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -6,7 +6,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Category, @@ -32,35 +31,28 @@ import { VALIDATION } from "../../shared/validation.ts"; function validateSlug(slug: string): void { if (!isValidSlugFormat(slug)) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, "Slug must be lowercase letters, numbers and hyphens", ); } if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, `Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`, ); } if (isReservedSlug(slug)) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - `Slug "${slug}" is reserved`, - ); + throw new APIException(400, `Slug "${slug}" is reserved`); } } function requireName(name: string): string { const trimmed = name.trim(); if (!trimmed) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required"); + throw new APIException(400, "Name is required"); } if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, `Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`, ); @@ -76,18 +68,10 @@ function requireName(name: string): string { function uniqueConflict(err: unknown, slug: string, name: string): never { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("categories.name")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - `Name "${name}" is already in use`, - ); + throw new APIException(409, `Name "${name}" is already in use`); } if (msg.includes("UNIQUE") || msg.includes("unique")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - `Slug "${slug}" is already in use`, - ); + throw new APIException(409, `Slug "${slug}" is already in use`); } throw err as Error; } @@ -100,7 +84,7 @@ function uniqueConflict(err: unknown, slug: string, name: string): never { export function getCategoryById(id: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id); if (!row || !isCategoryRow(row)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); + throw new APIException(404, "Category not found"); } return categoryRowToApi(row); } @@ -113,7 +97,7 @@ export function getCategoryById(id: string): Category { export function getCategoryBySlug(slug: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug); if (!row || !isCategoryRow(row)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); + throw new APIException(404, "Category not found"); } return categoryRowToApi(row); } @@ -231,7 +215,7 @@ export function updateCategory( export function deleteCategory(id: string): void { const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id); if (result.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); + throw new APIException(404, "Category not found"); } } @@ -251,11 +235,7 @@ export function assertCategoriesExist(categoryIds: string[]): void { `SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`, ).get(...unique) as { count: number } | undefined; if ((found?.count ?? 0) !== unique.length) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "One or more categories do not exist", - ); + throw new APIException(400, "One or more categories do not exist"); } } } diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index 2589e9fd..5595e4fc 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -8,7 +8,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { ChatMessage } from "../../shared/chat.ts"; import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts"; @@ -47,7 +46,7 @@ function fetchMessage(id: string): ChatMessage { `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, ).get(id); if (!row || !isChatMessageRow(row)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); + throw new APIException(404, "Message not found"); } return chatMessageRowToApi(row); } @@ -67,15 +66,10 @@ export function createChatMessage( ): ChatMessage { const trimmed = body.trim(); if (!trimmed) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Message cannot be empty", - ); + throw new APIException(400, "Message cannot be empty"); } if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); @@ -93,7 +87,7 @@ export function createChatMessage( `INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`, ).run(id, userId, trimmed, createdAt, replyTo); // Unclaimed attachments are deleted as orphans at startup. - linkAttachments(trimmed, id); + linkAttachments(trimmed, "chat", id); return fetchMessage(id); } @@ -103,7 +97,7 @@ function getMessageOwner(id: string): string { id, ) as { user_id: string } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); + throw new APIException(404, "Message not found"); } return row.user_id; } @@ -113,7 +107,7 @@ function getMessageOwner(id: string): string { * links to. `canModerate` lets a moderator edit anyone's message. The caller * broadcasts the result. * - * @throws {APIException} 404 when there is no such message, 401 when the user + * @throws {APIException} 404 when there is no such message, 403 when the user * is neither its author nor a moderator, 400 when the body is blank or too * long. */ @@ -125,30 +119,21 @@ export function updateChatMessage( ): ChatMessage { const ownerId = getMessageOwner(id); if (ownerId !== requestingUserId && !canModerate) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to edit this message", - ); + throw new APIException(403, "Not authorized to edit this message"); } const trimmed = body.trim(); if (!trimmed) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Message cannot be empty", - ); + throw new APIException(400, "Message cannot be empty"); } if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) .run(trimmed, new Date().toISOString(), id); - linkAttachments(trimmed, id); + linkAttachments(trimmed, "chat", id); return fetchMessage(id); } @@ -156,7 +141,7 @@ export function updateChatMessage( * Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete * anyone's message. Replies to it keep their `replyToId`, without its preview. * - * @throws {APIException} 404 when there is no such message, 401 when the user + * @throws {APIException} 404 when there is no such message, 403 when the user * is neither its author nor a moderator. */ export function deleteChatMessage( @@ -166,11 +151,7 @@ export function deleteChatMessage( ): void { const ownerId = getMessageOwner(id); if (ownerId !== requestingUserId && !canModerate) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to delete this message", - ); + throw new APIException(403, "Not authorized to delete this message"); } db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id); } @@ -195,11 +176,7 @@ export interface ChatCursor { /** Converts query rows to messages, or throws a 500 if one doesn't parse. */ function toMessages(rows: unknown[]): ChatMessage[] { if (!rows.every(isChatMessageRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed chat message data", - ); + throw new APIException(500, "Malformed chat message data"); } return rows.map(chatMessageRowToApi); } @@ -284,7 +261,7 @@ export function getChatReadState(userId: string): ChatReadState { FROM users u WHERE u.id = ?;`, ).get(userId) as { read_at: string; unread: number } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } return { readAt: row.read_at, unread: row.unread }; } @@ -301,11 +278,7 @@ export function getChatReadState(userId: string): ChatReadState { export function markChatRead(userId: string, at: string): ChatReadState { const parsed = new Date(at); if (Number.isNaN(parsed.getTime())) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid read marker", - ); + throw new APIException(400, "Invalid read marker"); } const newest = db.prepare( `SELECT MAX(created_at) AS newest FROM chat_messages;`, diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index 6dd34fea..c0cc892f 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -8,7 +8,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; import { db } from "../db/connection.ts"; @@ -36,7 +35,7 @@ function commentVisibility(commentId: string, userId: string): boolean { 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"); + throw new APIException(404, "Comment not found"); } return Boolean(row.is_private); } @@ -72,11 +71,7 @@ export function castCommentLike( db.exec("ROLLBACK;"); } catch { /* ignore if no active transaction */ } if (err instanceof Error && err.message.includes("UNIQUE constraint")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Already liked", - ); + throw new APIException(409, "Already liked"); } throw err; } @@ -110,7 +105,7 @@ export function removeCommentLike( ).run(commentId, userId); if (result.changes === 0) { db.exec("ROLLBACK;"); - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Like not found"); + throw new APIException(404, "Like not found"); } db.prepare( `UPDATE comments SET like_count = like_count - 1 WHERE id = ?;`, @@ -165,11 +160,7 @@ export function getCommentLikers( ).all(commentId, limit + 1, offset); if (!rawRows.every(isUserRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed user data", - ); + throw new APIException(500, "Malformed user data"); } return pageOf(rawRows.map(userRowToApi), limit); diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index d1d76f14..7f60cf85 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -6,7 +6,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Comment } from "../../shared/comment.ts"; import { type SQLOutputValue } from "node:sqlite"; @@ -29,14 +28,10 @@ function fetchComment(commentId: string): Comment { `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, ).get(commentId); if (!row || !isCommentRow(row as Record)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + throw new APIException(404, "Comment not found"); } if (!isCommentRow(row)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed comment data", - ); + throw new APIException(500, "Malformed comment data"); } return commentRowToApi(row); } @@ -52,7 +47,7 @@ export function getCommentDumpId(commentId: string): string { commentId, ) as { dump_id: string } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + throw new APIException(404, "Comment not found"); } return row.dump_id; } @@ -70,11 +65,7 @@ export function getComments(dumpId: string): Comment[] { WHERE c.dump_id = ? ORDER BY c.created_at ASC;`, ).all(dumpId); if (!rows.every(isCommentRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed comment data", - ); + throw new APIException(500, "Malformed comment data"); } return rows.map(commentRowToApi); } @@ -82,8 +73,11 @@ export function getComments(dumpId: string): Comment[] { /** * Posts a comment on dump `dumpId`, replying to `parentId` if given, for * `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the - * users it mentions, and links the comment's attachments and backlinks. - * `parentId` isn't checked to be a comment on the same dump. + * users it mentions, unless the dump is private, and links the comment's + * attachments and backlinks. The route has checked that the caller may see + * the dump. + * + * @throws {APIException} 400 when `parentId` isn't a comment on the same dump. */ export function createComment( dumpId: string, @@ -91,6 +85,13 @@ export function createComment( body: string, parentId?: string, ): Comment { + if (parentId !== undefined) { + const parent = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`) + .get(parentId) as { dump_id: string } | undefined; + if (parent?.dump_id !== dumpId) { + throw new APIException(400, "The comment replied to isn't on this dump"); + } + } const id = crypto.randomUUID(); const createdAt = new Date(); db.prepare( @@ -104,24 +105,27 @@ export function createComment( createdAt.toISOString(), ); const comment = fetchComment(id); - const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( - dumpId, - ) as { title: string } | undefined; + const dumpRow = db.prepare( + `SELECT title, is_private FROM dumps WHERE id = ?;`, + ).get(dumpId) as { title: string; is_private: number } | undefined; notifyDumpOwnerNewComment(userId, id, dumpId); - notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId); - linkAttachments(body, id); + if (dumpRow && !dumpRow.is_private) { + notifyMentions(userId, body, "comment", id, dumpRow.title, dumpId); + } + linkAttachments(body, "comment", id); relinkBacklinks("comment", id, dumpId, body); return comment; } /** * Replaces a comment's body, for `PATCH /api/comments/:commentId`. - * `canModerate` lets someone other than the author do it. It notifies the users - * it mentions for the first time and relinks the attachments and backlinks. The - * route broadcasts the comment unless `isPrivate`. + * `canModerate` lets someone other than the author do it. It notifies, in the + * author's name, the users it mentions for the first time, unless the dump is + * private, and relinks the attachments and backlinks. The route broadcasts the + * comment unless `isPrivate`. * * @throws {APIException} 404 when there is no such comment, 400 when it is - * deleted, 401 when the user is neither its author nor a moderator. + * deleted, 403 when the user is neither its author nor a moderator. */ export function updateComment( commentId: string, @@ -133,22 +137,14 @@ export function updateComment( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, ).get(commentId) as { dump_id: string; is_private: number } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + throw new APIException(404, "Comment not found"); } const existing = fetchComment(commentId); if (existing.deleted) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Cannot edit a deleted comment", - ); + throw new APIException(400, "Cannot edit a deleted comment"); } if (existing.userId !== requestingUserId && !canModerate) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to edit this comment", - ); + throw new APIException(403, "Not authorized to edit this comment"); } const now = new Date().toISOString(); db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run( @@ -156,18 +152,21 @@ export function updateComment( now, commentId, ); - const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( - row.dump_id, - ) as { title: string } | undefined; - notifyMentions( - requestingUserId, - body, - "comment", - commentId, - dumpRow?.title ?? "", - row.dump_id, - ); - linkAttachments(body, commentId); + if (!row.is_private) { + const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( + row.dump_id, + ) as { title: string } | undefined; + // A moderator's edit keeps the author's words, so the author mentions. + notifyMentions( + existing.userId, + body, + "comment", + commentId, + dumpRow?.title ?? "", + row.dump_id, + ); + } + linkAttachments(body, "comment", commentId); relinkBacklinks("comment", commentId, row.dump_id, body); return { comment: fetchComment(commentId), @@ -182,7 +181,7 @@ export function updateComment( * dropped, but the row stays so its replies keep their place. The route * broadcasts the deletion unless `isPrivate`. * - * @throws {APIException} 404 when there is no such comment, 401 when the user + * @throws {APIException} 404 when there is no such comment, 403 when the user * is neither its author nor a moderator. */ export function deleteComment( @@ -194,15 +193,11 @@ export function deleteComment( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, ).get(commentId) as { dump_id: string; is_private: number } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + throw new APIException(404, "Comment not found"); } const comment = fetchComment(commentId); if (comment.userId !== requestingUserId && !canModerate) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to delete this comment", - ); + throw new APIException(403, "Not authorized to delete this comment"); } db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run( commentId, diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index a651a451..6c410d50 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -9,7 +9,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { CreateUrlDumpRequest, @@ -31,11 +30,7 @@ import { tryFetchRichContent, } from "./rich-content-service.ts"; import { isValidHttpUrl } from "../remote/fetch.ts"; -import { - broadcastDumpDeleted, - broadcastDumpUpdated, - broadcastNewDump, -} from "./ws-service.ts"; +import { broadcastDumpChange } from "./ws-service.ts"; import { notifyMentions, notifyUserFollowersNewDump, @@ -72,14 +67,12 @@ function isAllowedMime(mime: string): boolean { 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`, ); @@ -109,7 +102,7 @@ export async function createUrlDump( userId: string, ): Promise { if (!isValidHttpUrl(request.url)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL"); + throw new APIException(400, "Invalid URL"); } assertCategoriesExist(request.categoryIds ?? []); @@ -159,13 +152,14 @@ export async function createUrlDump( isPrivate, categoryIds, }; - if (!isPrivate) { - broadcastNewDump(dump); - notifyUserFollowersNewDump(userId, dumpId, title); - } + broadcastDumpChange(null, dump); + if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title); if (request.comment) { - notifyMentions(userId, request.comment, "dump", dumpId, title); - linkAttachments(request.comment, dumpId); + // A private dump's mentions are notified if it goes public. + if (!isPrivate) { + notifyMentions(userId, request.comment, "dump", dumpId, title); + } + linkAttachments(request.comment, "dump", dumpId); } relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? ""); return dump; @@ -189,18 +183,10 @@ export async function createFileDump( ): Promise { assertFileDumpText(title, comment); if (!isAllowedMime(file.type)) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - `File type '${file.type}' is not allowed`, - ); + throw new APIException(400, `File type '${file.type}' is not allowed`); } if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "File too large (max 50 MB)", - ); + throw new APIException(400, "File too large (max 50 MB)"); } assertCategoriesExist(categoryIds); @@ -257,13 +243,14 @@ export async function createFileDump( isPrivate, categoryIds: dedupedCategoryIds, }; - if (!isPrivate) { - broadcastNewDump(dump); - notifyUserFollowersNewDump(userId, dumpId, finalTitle); - } + broadcastDumpChange(null, dump); + if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle); if (comment) { - notifyMentions(userId, comment, "dump", dumpId, finalTitle); - linkAttachments(comment, dumpId); + // A private dump's mentions are notified if it goes public. + if (!isPrivate) { + notifyMentions(userId, comment, "dump", dumpId, finalTitle); + } + linkAttachments(comment, "dump", dumpId); } relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? ""); return dump; @@ -307,7 +294,7 @@ function fetchDump(idOrSlug: string): Dump { idOrSlug, ); if (!row || !isDumpRow(row)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + throw new APIException(404, "Dump not found"); } return dumpRowToApi(row); } @@ -322,7 +309,7 @@ function fetchDump(idOrSlug: string): Dump { export function getDump(dumpId: string, requestingUserId?: string): Dump { const dump = fetchDump(dumpId); if (dump.isPrivate && dump.userId !== requestingUserId) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + throw new APIException(404, "Dump not found"); } attachCategoryIds([dump]); return dump; @@ -391,11 +378,7 @@ export function listDumps( ).all(limit + 1, offset); if (!rows || !rows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(500, "Malformed dump data"); } const items = rows.map(dumpRowToApi); @@ -500,19 +483,18 @@ export async function updateDump( } else { attachCategoryIds([updatedDump]); } - if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); - else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); - // Going public is an arrival for everyone who couldn't see it before. - if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump); + broadcastDumpChange(dump, updatedDump); if (updatedDump.comment) { - notifyMentions( - dump.userId, - updatedDump.comment, - "dump", - dumpId, - updatedDump.title, - ); - linkAttachments(updatedDump.comment, dumpId); + if (!updatedDump.isPrivate) { + notifyMentions( + dump.userId, + updatedDump.comment, + "dump", + dumpId, + updatedDump.title, + ); + } + linkAttachments(updatedDump.comment, "dump", dumpId); } relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? ""); return updatedDump; @@ -521,7 +503,7 @@ export async function updateDump( const newUrl = request.url ?? dump.url!; if (!isValidHttpUrl(newUrl)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL"); + throw new APIException(400, "Invalid URL"); } let { richContent, title } = dump; @@ -567,7 +549,7 @@ export async function updateDump( ); if (result.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + throw new APIException(404, "Dump not found"); } if (request.categoryIds !== undefined) { @@ -578,19 +560,18 @@ export async function updateDump( attachCategoryIds([updatedDump]); } - if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); - else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); - // Going public is an arrival for everyone who couldn't see it before. - if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump); + broadcastDumpChange(dump, updatedDump); if (updatedDump.comment) { - notifyMentions( - dump.userId, - updatedDump.comment, - "dump", - dumpId, - updatedDump.title, - ); - linkAttachments(updatedDump.comment, dumpId); + if (!updatedDump.isPrivate) { + notifyMentions( + dump.userId, + updatedDump.comment, + "dump", + dumpId, + updatedDump.title, + ); + } + linkAttachments(updatedDump.comment, "dump", dumpId); } relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? ""); return updatedDump; @@ -617,23 +598,15 @@ export async function replaceFileDump( ): Promise { assertFileDumpText(title, comment); if (!isAllowedMime(file.type)) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - `File type '${file.type}' is not allowed`, - ); + throw new APIException(400, `File type '${file.type}' is not allowed`); } if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "File too large (max 50 MB)", - ); + throw new APIException(400, "File too large (max 50 MB)"); } const dump = fetchDump(dumpId); if (dump.kind !== "file") { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump"); + throw new APIException(400, "Not a file dump"); } if (categoryIds !== undefined) assertCategoriesExist(categoryIds); @@ -696,8 +669,10 @@ export async function replaceFileDump( } if (comment) { - notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); - linkAttachments(comment, dumpId); + if (!finalPrivate) { + notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); + } + linkAttachments(comment, "dump", dumpId); } relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? ""); const updatedDump: Dump = { @@ -711,9 +686,7 @@ export async function replaceFileDump( isPrivate: finalPrivate, updatedAt: now, }; - if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); - else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); - if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump); + broadcastDumpChange(dump, updatedDump); return updatedDump; } @@ -736,11 +709,7 @@ export function getDumpsByUser( `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, ).all(userId, limit + 1, offset); if (!rows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(500, "Malformed dump data"); } const items = rows.map(dumpRowToApi); attachCategoryIds(items); @@ -783,11 +752,7 @@ export function getVotedDumpsByUser( } if (!rows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(500, "Malformed dump data"); } const items = rows.map(dumpRowToApi); attachCategoryIds(items); @@ -805,11 +770,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise { const dump = fetchDump(dumpId); if (dump.kind !== "url" || !dump.url) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Only URL dumps support metadata refresh", - ); + throw new APIException(400, "Only URL dumps support metadata refresh"); } // A failed fetch yields a stub with no title or thumbnail. Written over the @@ -819,7 +780,6 @@ export async function refreshDumpMetadata(dumpId: string): Promise { }); if (!ok) { throw new APIException( - APIErrorCode.SERVER_ERROR, 502, "Could not reach the page to refresh its metadata", ); @@ -833,7 +793,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise { `UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`, ).run(row.title, row.rich_content, row.id); - if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + broadcastDumpChange(dump, updatedDump); return updatedDump; } @@ -856,8 +816,7 @@ export function getDumpThumbnailInfo( /** * Records that a dump has a custom thumbnail of type `mime`, for - * `PUT /api/dumps/:dumpId/thumbnail`. The route writes the file after this - * call returns. + * `PUT /api/dumps/:dumpId/thumbnail`, once the route has written the file. * * @throws {APIException} 404 when there is no such dump. */ @@ -868,7 +827,7 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump { dump.id, ); const updatedDump: Dump = { ...dump, thumbnailMime: mime }; - if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + broadcastDumpChange(dump, updatedDump); return updatedDump; } @@ -884,7 +843,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise { .run(dump.id); await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {}); const updatedDump: Dump = { ...dump, thumbnailMime: undefined }; - if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + broadcastDumpChange(dump, updatedDump); return updatedDump; } @@ -900,7 +859,7 @@ export async function deleteDump(dumpId: string): Promise { const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId); if (result.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + throw new APIException(404, "Dump not found"); } if (dump.kind === "file") { @@ -911,5 +870,5 @@ export async function deleteDump(dumpId: string): Promise { await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {}); } - broadcastDumpDeleted(dumpId); + broadcastDumpChange(dump, null); } diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 5803a76c..bdd7a4f0 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -6,7 +6,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Dump } from "../../shared/dump.ts"; import type { FollowStatus } from "../../shared/follow.ts"; @@ -35,11 +34,7 @@ import { attachCategoryIds } from "./category-service.ts"; */ export function followUser(followerId: string, followedUserId: string): void { if (followerId === followedUserId) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Cannot follow yourself", - ); + throw new APIException(400, "Cannot follow yourself"); } // The unique index answers "already following" without a race. let isNew = true; @@ -85,14 +80,10 @@ export function followPlaylist(followerId: string, playlistId: string): void { ).get(playlistId) as { id: string; is_public: number } | undefined; if (!row) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + throw new APIException(404, "Playlist not found"); } if (!row.is_public) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 403, - "Cannot follow a private playlist", - ); + throw new APIException(403, "Cannot follow a private playlist"); } let isNew = true; @@ -142,11 +133,7 @@ export function getFollowStatus(followerId: string): FollowStatus { ).all(followerId); if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed follow data", - ); + throw new APIException(500, "Malformed follow data"); } return { @@ -188,11 +175,7 @@ export function getFollowedUsersDumpFeed( const userFeedRows = rawRows as Parameters[0][]; if (!userFeedRows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(500, "Malformed dump data"); } const items = userFeedRows.map(dumpRowToApi); attachCategoryIds(items); @@ -236,11 +219,7 @@ export function getFollowedPlaylistsDumpFeed( ).all(...catParams, followerId, limit + 1, offset); if (!rawRows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(500, "Malformed dump data"); } const items = rawRows.map(dumpRowToApi); attachCategoryIds(items); @@ -273,11 +252,7 @@ export function getFollowedPlaylistsByUser( ).all(userId, limit + 1, offset); if (!rawRows.every(isPlaylistRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed playlist data", - ); + throw new APIException(500, "Malformed playlist data"); } return pageOf(rawRows.map(playlistRowToApi), limit); } @@ -309,11 +284,7 @@ export function getFollowedUsersByUser( ).all(userId, limit + 1, offset); if (!rawRows.every(isUserRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed user data", - ); + throw new APIException(500, "Malformed user data"); } return pageOf(rawRows.map(userRowToApi), limit); diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts index 5a919b95..595ef9f4 100644 --- a/api/services/invite-service.ts +++ b/api/services/invite-service.ts @@ -7,7 +7,6 @@ */ import { randomBytes } from "node:crypto"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db } from "../db/connection.ts"; import { isInviteRow } from "../model/invite.ts"; @@ -44,29 +43,17 @@ export function validateInvite(token: string): string { ).get(token); if (!row || !isInviteRow(row)) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Invalid or expired invite", - ); + throw new APIException(404, "Invalid or expired invite"); } if (row.used_at !== null) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Invite already used", - ); + throw new APIException(409, "Invite already used"); } const expiresAt = new Date(row.created_at).getTime() + UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000; if (Date.now() > expiresAt) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Invalid or expired invite", - ); + throw new APIException(404, "Invalid or expired invite"); } return row.inviter_id; @@ -86,11 +73,7 @@ export function claimInvite(token: string): string { `UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`, ).run(new Date().toISOString(), token); if (result.changes === 0) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Invite already used", - ); + throw new APIException(409, "Invite already used"); } return inviterId; } diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index c4c167c0..4f84f80b 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -14,7 +14,6 @@ import type { NotificationType, UserDumpPostedData, } from "../../shared/notification.ts"; -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; import { @@ -88,11 +87,7 @@ export function getNotificationsForUser( ).all(userId, limit + 1, offset); if (!rawRows.every(isNotificationRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed notification data", - ); + throw new APIException(500, "Malformed notification data"); } return pageOf(rawRows.map(notificationRowToApi), limit); } diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index 774c097a..79476f06 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -7,7 +7,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db } from "../db/connection.ts"; import { isPasswordResetTokenRow } from "../model/password-reset.ts"; @@ -88,7 +87,6 @@ export async function resetPassword( newPassword.length > VALIDATION.PASSWORD_MAX ) { throw new APIException( - APIErrorCode.VALIDATION_ERROR, 400, `Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`, ); @@ -96,11 +94,7 @@ export async function resetPassword( const payload = await verifyPasswordResetToken(token); if (!payload) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid or expired reset link", - ); + throw new APIException(400, "Invalid or expired reset link"); } const row = db.prepare( @@ -108,11 +102,7 @@ export async function resetPassword( ).get(token); if (!row || !isPasswordResetTokenRow(row)) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Reset token not found", - ); + throw new APIException(404, "Reset token not found"); } // Claim the link before the async hash: checking `used_at` first and @@ -121,11 +111,7 @@ export async function resetPassword( `UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`, ).run(new Date().toISOString(), token); if (claimed.changes === 0) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Reset link has already been used", - ); + throw new APIException(409, "Reset link has already been used"); } try { diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 38697c6f..901297e3 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -7,7 +7,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { CreatePlaylistRequest, @@ -63,15 +62,15 @@ export function getPlaylistById(idOrSlug: string): Playlist { ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) : db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug); if (!row || !isPlaylistRow(row)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + throw new APIException(404, "Playlist not found"); } return playlistRowToApi(row); } /** - * Creates a playlist owned by `userId`, for `POST /api/playlists`. It notifies - * the users its description mentions, links the attachments it embeds, and - * broadcasts `playlist_created`. + * Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is + * public, it notifies the users its description mentions. It links the + * attachments the description embeds and broadcasts `playlist_created`. */ export function createPlaylist( req: CreatePlaylistRequest, @@ -102,8 +101,11 @@ export function createPlaylist( createdAt, }; if (req.description) { - notifyMentions(userId, req.description, "playlist", id, req.title); - linkAttachments(req.description, id); + // A private playlist's mentions are notified if it goes public. + if (req.isPublic) { + notifyMentions(userId, req.description, "playlist", id, req.title); + } + linkAttachments(req.description, "playlist", id); } broadcastPlaylistCreated(playlist); return playlist; @@ -124,7 +126,7 @@ export function getPlaylist( const playlist = getPlaylistById(playlistId); if (!playlist.isPublic && requestingUserId !== playlist.userId) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + throw new APIException(404, "Playlist not found"); } const rows = db.prepare( @@ -195,8 +197,9 @@ export function listPlaylistsByUser( /** * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns * it as it now is. The slug follows the title. `canModerate` lets someone - * other than the owner edit it. It notifies the users the description - * mentions, links its attachments and broadcasts `playlist_updated`. + * other than the owner edit it. If the playlist is public, it notifies, in the + * owner's name, the users the description mentions. It links the description's + * attachments and broadcasts `playlist_updated`. * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator. @@ -210,7 +213,7 @@ export function updatePlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } const newTitle = req.title ?? playlist.title; @@ -243,14 +246,16 @@ export function updatePlaylist( updatedAt: now, }; if (newDescription) { - notifyMentions( - requestingUserId, - newDescription, - "playlist", - playlist.id, - newTitle, - ); - linkAttachments(newDescription, playlist.id); + if (newIsPublic) { + notifyMentions( + playlist.userId, + newDescription, + "playlist", + playlist.id, + newTitle, + ); + } + linkAttachments(newDescription, "playlist", playlist.id); } broadcastPlaylistUpdated(updated, playlist.isPublic); return updated; @@ -271,7 +276,7 @@ export function deletePlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); @@ -293,7 +298,7 @@ export function getEditablePlaylist( ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } return playlist; } @@ -347,7 +352,7 @@ export function addDumpToPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } // Else someone else's private dump would land in the playlist, and its title @@ -358,7 +363,7 @@ export function addDumpToPlaylist( | { 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"); + throw new APIException(404, "Dump not found"); } const minRow = db.prepare( @@ -376,11 +381,7 @@ export function addDumpToPlaylist( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("UNIQUE") || msg.includes("unique")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Dump already in playlist", - ); + throw new APIException(409, "Dump already in playlist"); } throw err; } @@ -415,7 +416,7 @@ export function removeDumpFromPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } db.prepare( @@ -443,7 +444,7 @@ export function reorderPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + throw new APIException(403, "Forbidden"); } // The requester can only name the dumps GET showed them. A hidden one @@ -461,7 +462,6 @@ export function reorderPlaylist( !visibleIds.every((id) => newSet.has(id)) ) { throw new APIException( - APIErrorCode.BAD_REQUEST, 400, "dumpIds must match current playlist members exactly", ); @@ -504,11 +504,7 @@ export function getPlaylistMembershipsForDump( return rows.map((row) => { if (!isPlaylistRow(row)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed playlist data", - ); + throw new APIException(500, "Malformed playlist data"); } return { playlist: playlistRowToApi(row), @@ -551,7 +547,7 @@ export function getPlaylistImageInfo( ): { id: string; imageMime: string; isPublic: boolean } | undefined { const playlist = getPlaylistById(idOrSlug); if (!playlist.isPublic && requestingUserId !== playlist.userId) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + throw new APIException(404, "Playlist not found"); } return playlist.imageMime ? { diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 4148d700..4578ef77 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -7,7 +7,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Invitee, @@ -27,7 +26,7 @@ import { import { db } from "../db/connection.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts"; -import { hashPassword } from "../lib/jwt.ts"; +import { hashPassword } from "../lib/password.ts"; import { linkAttachments } from "./attachment-service.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ @@ -42,8 +41,8 @@ const USER_SELECT = * `POST /api/users/register`. The caller has validated `request` and claimed * the invite. * - * @throws {APIException} 400 when the username is taken, 409 when the email - * is. + * @throws {APIException} 409 when the username or the email is taken, in any + * case. */ export async function createUser( request: RegisterUserRequest, @@ -53,15 +52,11 @@ export async function createUser( const createdAt = new Date(); const existingUser = db.prepare( - "SELECT id FROM users WHERE username = ?;", + "SELECT id FROM users WHERE lower(username) = lower(?);", ).get(request.username); if (existingUser) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Username already exists", - ); + throw new APIException(409, "Username already exists"); } assertEmailAvailable(request.email); @@ -82,16 +77,9 @@ export async function createUser( request.email, ); } catch (err) { - // A parallel sign-up can take the name while the hash is computed; the - // UNIQUE constraint is the real guard. Emails have no such constraint. - if (err instanceof Error && err.message.includes("UNIQUE")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Username already exists", - ); - } - throw err; + // A parallel sign-up can take the name or the address while the hash is + // computed; the unique indexes are the real guard. + throw uniqueViolationToConflict(err); } return { @@ -115,7 +103,7 @@ export function getUserById(userId: string): User { ).get(userId); if (!userRow || !isUserRow(userRow)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } return userRowToApi(userRow); @@ -152,7 +140,7 @@ export function getUserByUsername(username: string): User { ).get(username); if (!userRow || !isUserRow(userRow)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } return userRowToApi(userRow); @@ -168,21 +156,28 @@ function assertEmailAvailable(email: string, exceptUserId?: string): void { `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", - ); + throw new APIException(409, "Email already in use"); } } /** - * Returns the user whose email is exactly `email`, or null, for - * `requestPasswordReset`. Unlike the uniqueness check, it is case-sensitive. + * Returns `err` as a 409 when it is SQLite's unique violation on a username or + * an email, and as it is otherwise. + */ +function uniqueViolationToConflict(err: unknown): unknown { + if (!(err instanceof Error) || !err.message.includes("UNIQUE")) return err; + return err.message.includes("email") + ? new APIException(409, "Email already in use") + : new APIException(409, "Username already exists"); +} + +/** + * Returns the user whose email is `email`, in any case, or null, for + * `requestPasswordReset`. */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( - `${USER_SELECT} WHERE u.email = ?`, + `${USER_SELECT} WHERE lower(u.email) = lower(?)`, ).get(email); if (!userRow || !isUserRow(userRow)) return null; @@ -236,7 +231,7 @@ export function listUsers(): User[] { ).all(); if (!userRows || !userRows.every(isUserRow)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "No user found"); + throw new APIException(404, "No user found"); } return userRows.map(userRowToApi); @@ -275,20 +270,25 @@ export async function updateUser( const updatedUserRow = userApiToRow(updatedUser); - const userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, - ).run( - updatedUserRow.username, - updatedUserRow.password_hash, - updatedUserRow.role, - updatedUserRow.description, - updatedUserRow.email, - now.toISOString(), - updatedUserRow.id, - ); + let userResult; + try { + userResult = db.prepare( + `UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, + ).run( + updatedUserRow.username, + updatedUserRow.password_hash, + updatedUserRow.role, + updatedUserRow.description, + updatedUserRow.email, + now.toISOString(), + updatedUserRow.id, + ); + } catch (err) { + throw uniqueViolationToConflict(err); + } if (userResult.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } if (password) { @@ -300,7 +300,7 @@ export async function updateUser( } if (updatedUser.description) { - linkAttachments(updatedUser.description, userId); + linkAttachments(updatedUser.description, "user", userId); } return updatedUser; @@ -319,7 +319,7 @@ export function updateUserAvatar(userId: string, mime: string): void { ).run(mime, new Date().toISOString(), userId); if (result.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } } @@ -371,6 +371,6 @@ export function deleteUser(userId: string): void { ).run(userId); if (result.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + throw new APIException(404, "User not found"); } } diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index b9da0575..41811ec6 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -8,7 +8,6 @@ * @module */ -import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; import { db } from "../db/connection.ts"; @@ -34,7 +33,7 @@ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } { `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"); + throw new APIException(404, "Dump not found"); } return { isPrivate: Boolean(row.is_private) }; } @@ -67,11 +66,7 @@ export function castVote(dumpId: string, userId: string): VoteResult { db.exec("ROLLBACK;"); } catch { /* ignore if no active transaction */ } if (err instanceof Error && err.message.includes("UNIQUE constraint")) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 409, - "Already voted", - ); + throw new APIException(409, "Already voted"); } throw err; } @@ -101,7 +96,7 @@ export function removeVote(dumpId: string, userId: string): VoteResult { ).run(dumpId, userId); if (result.changes === 0) { db.exec("ROLLBACK;"); - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Vote not found"); + throw new APIException(404, "Vote not found"); } db.prepare( `UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`, @@ -155,11 +150,7 @@ export function getDumpVoters( ).all(dumpId, limit + 1, offset); if (!rawRows.every(isUserRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed user data", - ); + throw new APIException(500, "Malformed user data"); } return pageOf(rawRows.map(userRowToApi), limit); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 58fef5d3..846ace3f 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -169,27 +169,43 @@ export function broadcastPresence(): void { } /** - * Sends everyone `dump_created`. The dump service also sends it when a private - * dump goes public, since to everyone else that dump is new. + * Tells the clients that may see a dump how it changed, for the dump service + * after every write. `before` is null for a new dump, `after` for a deleted + * one. A private dump's changes reach its owner's sockets alone. When a dump + * goes private, everyone else gets `dump_deleted`; when it goes public, they get + * `dump_created`, since to them it is new. */ -export function broadcastNewDump(dump: Dump): void { - broadcast({ type: "dump_created", dump }); -} +export function broadcastDumpChange( + before: Dump | null, + after: Dump | null, +): void { + const dump = after ?? before; + if (!dump) return; + const ownerId = dump.userId; + const toOwner = (client: WsClient) => client.userId === ownerId; + const toOthers = (client: WsClient) => client.userId !== ownerId; -/** - * Sends everyone `dump_updated`, for a public dump that changed. The dump - * service calls it. - */ -export function broadcastDumpUpdated(dump: Dump): void { - broadcast({ type: "dump_updated", dump }); -} - -/** - * Sends everyone `dump_deleted`. The dump service also sends it when a public - * dump goes private, so that everyone else drops it. - */ -export function broadcastDumpDeleted(dumpId: string): void { - broadcast({ type: "dump_deleted", dumpId }); + if (!after) { + broadcast( + { type: "dump_deleted", dumpId: dump.id }, + dump.isPrivate ? toOwner : undefined, + ); + } else if (!before) { + broadcast( + { type: "dump_created", dump: after }, + after.isPrivate ? toOwner : undefined, + ); + } else if (after.isPrivate) { + broadcast({ type: "dump_updated", dump: after }, toOwner); + if (!before.isPrivate) { + broadcast({ type: "dump_deleted", dumpId: after.id }, toOthers); + } + } else if (before.isPrivate) { + broadcast({ type: "dump_updated", dump: after }, toOwner); + broadcast({ type: "dump_created", dump: after }, toOthers); + } else { + broadcast({ type: "dump_updated", dump: after }); + } } /** diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index 08abbd11..b4159715 100644 --- a/api/test/db/migrate.test.ts +++ b/api/test/db/migrate.test.ts @@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => .get() as { n: number; }; - assertEquals(applied.n, 16); + assertEquals(applied.n, 18); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 16, + 18, ); db.close(); }); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 42fdccca..1ee759a2 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -4,7 +4,8 @@ import { TEST_DIR } from "./setup.ts"; import { stub } from "@std/testing/mock"; import { app } from "../main.ts"; import { db } from "../db/connection.ts"; -import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts"; +import { createJWT, createMediaToken } from "../lib/jwt.ts"; +import { hashPassword } from "../lib/password.ts"; import { makeSlug } from "../lib/slugify.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; diff --git a/api/test/lib/jwt.test.ts b/api/test/lib/jwt.test.ts index 92b870ed..c729c36e 100644 --- a/api/test/lib/jwt.test.ts +++ b/api/test/lib/jwt.test.ts @@ -1,35 +1,17 @@ import "../setup.ts"; -import { - assert, - assertEquals, - assertFalse, - assertNotEquals, -} from "@std/assert"; +import { assertEquals } from "@std/assert"; import { SignJWT } from "@panva/jose"; import { createJWT, createMediaToken, createPasswordResetToken, - hashPassword, verifyJWT, verifyMediaToken, - verifyPassword, verifyPasswordResetToken, } from "../../lib/jwt.ts"; const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret"); -Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => { - const a = await hashPassword("hunter22"); - const b = await hashPassword("hunter22"); - assertNotEquals(a, b); - assert(await verifyPassword("hunter22", a)); - assertFalse(await verifyPassword("hunter23", a)); - // A malformed stored hash never verifies. - assertFalse(await verifyPassword("hunter22", "not-a-hash")); - assertFalse(await verifyPassword("hunter22", "")); -}); - Deno.test("session, media and reset tokens don't stand in for each other", async () => { const userId = crypto.randomUUID(); const session = await createJWT({ userId, username: "u", role: "user" }); diff --git a/api/test/lib/password.test.ts b/api/test/lib/password.test.ts new file mode 100644 index 00000000..544426fa --- /dev/null +++ b/api/test/lib/password.test.ts @@ -0,0 +1,13 @@ +import { assert, assertFalse, assertNotEquals } from "@std/assert"; +import { hashPassword, verifyPassword } from "../../lib/password.ts"; + +Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => { + const a = await hashPassword("hunter22"); + const b = await hashPassword("hunter22"); + assertNotEquals(a, b); + assert(await verifyPassword("hunter22", a)); + assertFalse(await verifyPassword("hunter23", a)); + // A malformed stored hash never verifies. + assertFalse(await verifyPassword("hunter22", "not-a-hash")); + assertFalse(await verifyPassword("hunter22", "")); +}); diff --git a/api/test/middleware/error.test.ts b/api/test/middleware/error.test.ts index 57eb8309..55d85651 100644 --- a/api/test/middleware/error.test.ts +++ b/api/test/middleware/error.test.ts @@ -2,7 +2,6 @@ import { BASE_URL } from "../helpers.ts"; import { assertEquals } from "@std/assert"; import { Application } from "@oak/oak"; import { errorMiddleware } from "../../middleware/error.ts"; -import { APIErrorCode } from "../../../shared/api.ts"; import { APIException } from "../../lib/api-exception.ts"; async function handle( @@ -20,7 +19,7 @@ Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a app.use(errorMiddleware); app.use((ctx) => { if (ctx.request.url.pathname === "/api-error") { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope"); + throw new APIException(404, "Nope"); } throw new Error("secret internals"); }); diff --git a/api/test/remote/providers/bandcamp.test.ts b/api/test/remote/providers/bandcamp.test.ts index ec2414c6..b53bea43 100644 --- a/api/test/remote/providers/bandcamp.test.ts +++ b/api/test/remote/providers/bandcamp.test.ts @@ -1,6 +1,7 @@ import { html, stubNetwork } from "../../helpers.ts"; -import { assertEquals } from "@std/assert"; +import { assert, assertEquals, assertFalse } from "@std/assert"; import { fetchRichContent } from "../../../services/rich-content-service.ts"; +import { bandcampProvider } from "../../../remote/providers/bandcamp.ts"; Deno.test("bandcamp provider", async () => { using _ = stubNetwork(() => @@ -12,3 +13,10 @@ Deno.test("bandcamp provider", async () => { assertEquals(bc?.title, "Album"); assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1"); }); + +Deno.test("bandcamp provider: bandcamp.com and its subdomains only", () => { + assert(bandcampProvider.matches("https://artist.bandcamp.com/album/x")); + assertFalse( + bandcampProvider.matches("https://bandcamp.com.evil.example/album/x"), + ); +}); diff --git a/api/test/remote/providers/self.test.ts b/api/test/remote/providers/self.test.ts index f7419dc6..8b584638 100644 --- a/api/test/remote/providers/self.test.ts +++ b/api/test/remote/providers/self.test.ts @@ -1,4 +1,10 @@ -import { createUser, insertDump, stubNetwork, test } from "../../helpers.ts"; +import { + createUser, + insertDump, + insertPlaylist, + stubNetwork, + test, +} from "../../helpers.ts"; import { assertEquals } from "@std/assert"; import { fetchRichContent } from "../../../services/rich-content-service.ts"; @@ -26,3 +32,19 @@ test("self provider: our own dump, user and playlist pages come from the DB", as assertEquals(profile?.title, user.username); assertEquals(net.calls.length, 0); }); + +test("self provider: a private playlist gets no preview", async () => { + const user = await createUser(); + const playlist = insertPlaylist({ + userId: user.id, + title: "Secret mix", + isPublic: false, + }); + using _net = stubNetwork(() => { + throw new Error("no network for self links"); + }); + const content = await fetchRichContent( + `http://gerbeur.test/playlists/${playlist.id}`, + ); + assertEquals(content?.title, undefined); +}); diff --git a/api/test/remote/providers/youtube.test.ts b/api/test/remote/providers/youtube.test.ts index dcaf203b..4ed14ab6 100644 --- a/api/test/remote/providers/youtube.test.ts +++ b/api/test/remote/providers/youtube.test.ts @@ -1,6 +1,7 @@ import { html, stubNetwork } from "../../helpers.ts"; -import { assertEquals } from "@std/assert"; +import { assert, assertEquals, assertFalse } from "@std/assert"; import { fetchRichContent } from "../../../services/rich-content-service.ts"; +import { youtubeProvider } from "../../../remote/providers/youtube.ts"; Deno.test("youtube provider: video with start time and playlist", async () => { using net = stubNetwork((url) => @@ -55,3 +56,18 @@ Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () assertEquals(channel?.title, "Channel"); assertEquals(channel?.embedUrl, undefined); }); + +Deno.test("youtube provider: every YouTube host, and no other site's list", () => { + for ( + const url of [ + "https://m.youtube.com/watch?v=dQw4w9WgXcQ", + "https://music.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", + "https://www.youtube.com/live/dQw4w9WgXcQ", + "https://m.youtube.com/@someone", + ] + ) { + assert(youtubeProvider.matches(url), url); + } + assertFalse(youtubeProvider.matches("https://example.com/page?list=PL123")); +}); diff --git a/api/test/routes/attachments.test.ts b/api/test/routes/attachments.test.ts index c00f61fb..ed51b8f1 100644 --- a/api/test/routes/attachments.test.ts +++ b/api/test/routes/attachments.test.ts @@ -2,6 +2,7 @@ import { count, createUser, db, + del, formWithFile, get, insertDump, @@ -12,6 +13,7 @@ import { import type { AttachmentResponse } from "../../../shared/dump.ts"; import type { Comment } from "../../../shared/comment.ts"; import { assertEquals } from "@std/assert"; +import { runStartupTasks } from "../../db/startup.ts"; test("attachments: upload an image, serve it back", async () => { const user = await createUser(); @@ -88,3 +90,48 @@ test("attachments are claimed by the first text that links them", async () => { }); assertEquals(owner(), comment.data.id); }); + +test("attachments in a private dump need its owner's media token", async () => { + const owner = await createUser(); + const other = await createUser(); + const up = await post("/api/attachments", { + auth: owner.auth, + form: formWithFile(PNG), + }); + const id = up.data.id; + const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true }); + await post(`/api/dumps/${dumpId}/comments`, { + auth: owner.auth, + json: { body: `![img](/api/attachments/${id})` }, + }); + assertEquals((await get(`/api/attachments/${id}`)).status, 404); + assertEquals( + (await get(`/api/attachments/${id}?token=${other.mediaToken}`)).status, + 404, + ); + const served = await get(`/api/attachments/${id}?token=${owner.mediaToken}`); + assertEquals(served.status, 200); + assertEquals(served.headers.get("cache-control"), "private, max-age=3600"); +}); + +test("attachments of a deleted resource are gone, and purged at startup", async () => { + const user = await createUser(); + const up = await post("/api/attachments", { + auth: user.auth, + form: formWithFile(PNG), + }); + const id = up.data.id; + const { id: dumpId } = insertDump({ userId: user.id }); + const comment = await post(`/api/dumps/${dumpId}/comments`, { + auth: user.auth, + json: { body: `![img](/api/attachments/${id})` }, + }); + assertEquals((await get(`/api/attachments/${id}`)).status, 200); + await del(`/api/comments/${comment.data.id}`, { auth: user.auth }); + assertEquals((await get(`/api/attachments/${id}`)).status, 404); + await runStartupTasks(db); + assertEquals( + count(`SELECT COUNT(*) as n FROM attachments WHERE id = ?;`, id), + 0, + ); +}); diff --git a/api/test/routes/comments.test.ts b/api/test/routes/comments.test.ts index 1816076a..4616ebf1 100644 --- a/api/test/routes/comments.test.ts +++ b/api/test/routes/comments.test.ts @@ -4,10 +4,13 @@ import { db, del, get, + html, insertComment, insertDump, patch, post, + put, + stubNetwork, test, } from "../helpers.ts"; import { assertEquals, assertExists, assertFalse } from "@std/assert"; @@ -135,7 +138,7 @@ test("PATCH /api/comments/:id: author and moderator edit, others can't", async ( json: { body: "x" }, })) .status, - 401, + 403, ); const edited = await patch(`/api/comments/${commentId}`, { auth: author.auth, @@ -180,7 +183,7 @@ test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edit }); assertEquals( (await del(`/api/comments/${commentId}`, { auth: other.auth })).status, - 401, + 403, ); assertEquals( (await del(`/api/comments/${commentId}`, { auth: author.auth })).status, @@ -264,3 +267,53 @@ test("comment likes on a private dump: owner only", async () => { 200, ); }); + +test("comments: a reply must answer a comment on the same dump", async () => { + const user = await createUser(); + const { id: a } = insertDump({ userId: user.id }); + const { id: b } = insertDump({ userId: user.id }); + const onB = insertComment({ dumpId: b, userId: user.id, body: "on b" }); + for (const parentId of [onB, crypto.randomUUID()]) { + assertEquals( + (await post(`/api/dumps/${a}/comments`, { + auth: user.auth, + json: { body: "reply", parentId }, + })).status, + 400, + ); + } + assertEquals( + count(`SELECT COUNT(*) as n FROM comments WHERE dump_id = ?;`, a), + 0, + ); +}); + +test("mentions in a private dump or its comments wait for it to go public", async () => { + using _net = stubNetwork(() => html("Note")); + const owner = await createUser(); + const friend = await createUser(); + const mentions = () => + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned' AND user_id = ?;`, + friend.id, + ); + const dump = await post("/api/dumps", { + auth: owner.auth, + json: { + url: "https://example.com/private-note", + comment: `for @${friend.username}`, + isPrivate: true, + }, + }); + assertEquals(dump.status, 201); + await post(`/api/dumps/${dump.data.id}/comments`, { + auth: owner.auth, + json: { body: `hey @${friend.username}` }, + }); + assertEquals(mentions(), 0); + await put(`/api/dumps/${dump.data.id}`, { + auth: owner.auth, + json: { isPrivate: false }, + }); + assertEquals(mentions(), 1); +}); diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index 0a27a528..1d8af497 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -447,7 +447,7 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async ( assertEquals((await get(`/api/dumps/${id}`)).data.title, "Moderated"); }); -test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => { +test("PUT /api/dumps/:id: invalid input is 400, a new URL refetches metadata", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id, @@ -457,7 +457,7 @@ test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", a assertEquals( (await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: " " } })) .status, - 422, + 400, ); assertEquals( (await put(`/api/dumps/${id}`, { diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index 5493e06a..a9643184 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -339,13 +339,13 @@ test("POST /api/users/register: a taken name or email hands the invite back", as const inviteToken = invite(inviter.id); const byName = await post("/api/users/register", { json: { - username: taken.username, + username: taken.username.toUpperCase(), password: "longenough", email: "fresh@example.test", inviteToken, }, }); - assertEquals(byName.status, 400); + assertEquals(byName.status, 409); const byEmail = await post("/api/users/register", { json: { username: "fresh_name", @@ -411,7 +411,7 @@ test("change-password: needs the current one, revokes old tokens, returns new on auth: user.auth, json: { currentPassword: "nope", newPassword: "brand-new-pass" }, }); - assertEquals(wrong.status, 401); + assertEquals(wrong.status, 403); const short = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "short" }, diff --git a/api/test/routes/ws.test.ts b/api/test/routes/ws.test.ts index 48f15e01..eb09254f 100644 --- a/api/test/routes/ws.test.ts +++ b/api/test/routes/ws.test.ts @@ -3,6 +3,7 @@ import { count, createUser, db, + del, formWithFile, get, insertComment, @@ -507,30 +508,48 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { const o = await connect(owner); const x = await connect(other); - // A public file dump arrives for everyone; a private one for no one. + // A public file dump arrives for everyone; a private one for its owner's + // other tabs alone. const pub = await post("/api/dumps", { auth: owner.auth, form: formWithFile(PNG), }); assertEquals((await x.next("dump_created")).dump.id, pub.data.id); - await post("/api/dumps", { + const secret = await post("/api/dumps", { auth: owner.auth, form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }), }); + await o.next("dump_created", (m) => m.dump.id === secret.data.id); await x.sync(); assertEquals(x.all("dump_created").length, 1); - // Going private is a deletion for everyone else; going public an arrival. + // A private dump's edits and deletion reach its owner alone. + await put(`/api/dumps/${secret.data.id}`, { + auth: owner.auth, + json: { title: "Still secret" }, + }); + await o.next("dump_updated", (m) => m.dump.title === "Still secret"); + await del(`/api/dumps/${secret.data.id}`, { auth: owner.auth }); + await o.next("dump_deleted", (m) => m.dumpId === secret.data.id); + await x.sync(); + assertEquals(x.all("dump_updated"), []); + assertEquals(x.all("dump_deleted"), []); + + // Going private is a deletion for everyone else, and an update for the + // owner, who still has it; going public is an arrival for everyone else. await put(`/api/dumps/${pub.data.id}`, { auth: owner.auth, json: { isPrivate: true }, }); assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id); + assert((await o.next("dump_updated")).dump.isPrivate); await put(`/api/dumps/${pub.data.id}`, { auth: owner.auth, json: { isPrivate: false }, }); await x.next("dump_created", (m) => m.dump.id === pub.data.id); + await o.sync(); + assertEquals(o.all("dump_deleted").length, 1); // Comments on a private dump stay quiet. const priv = insertDump({ userId: owner.id, isPrivate: true }); diff --git a/api/test/setup.ts b/api/test/setup.ts index 8c685f95..d2cd63d4 100644 --- a/api/test/setup.ts +++ b/api/test/setup.ts @@ -1,11 +1,10 @@ // Points the app at a throwaway database and uploads directory before any app // module is evaluated. api/config.ts reads the environment and api/db/connection.ts -// opens the database at import time, so this module must be the first import -// of every test file — directly, or as helpers.ts's first import. It imports -// nothing from the app itself, and it is synchronous on purpose: a module with -// a top-level await doesn't hold back the sibling imports that follow it. -import { DatabaseSync } from "node:sqlite"; - +// opens the database at import time, creating it from schema.sql, so this +// module must be the first import of every test file — directly, or as +// helpers.ts's first import. It imports nothing from the app itself, and it is +// synchronous on purpose: a module with a top-level await doesn't hold back +// the sibling imports that follow it. export const TEST_DIR = Deno.makeTempDirSync({ prefix: "gerbeur-test-" }); Deno.env.set("GERBEUR_DB_PATH", `${TEST_DIR}/gerbeur.db`); @@ -22,10 +21,3 @@ if (!Deno.env.get("TEST_VERBOSE")) { console.log = () => {}; console.error = () => {}; } - -// A fresh database is built from schema.sql, exactly as api/db/init.ts does. -const db = new DatabaseSync(`${TEST_DIR}/gerbeur.db`); -db.exec( - Deno.readTextFileSync(new URL("../db/schema.sql", import.meta.url)), -); -db.close(); diff --git a/deno.json b/deno.json index 230ad01b..0874604f 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,7 @@ "serve": "deno task build && deno task icons dist && deno task server:start", "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", - "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts", + "start": "deno task icons dist && deno run --env-file -A api/main.ts", "test": "deno test -A api/ shared/", "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/", "test:web": "deno run -A npm:vitest run", diff --git a/shared/api.ts b/shared/api.ts index ad19f58d..140b2b3b 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -7,22 +7,21 @@ */ /** - * The codes an {@link APIError} may carry. The frontend shows the message and - * doesn't read the code, and a code goes with several statuses. + * The codes an {@link APIError} may carry, one per HTTP status, which the + * backend's `APIException` derives from the status it is given. The frontend + * shows the message and doesn't read the code. */ export const APIErrorCode = { BAD_REQUEST: "BAD_REQUEST", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", /** Also answers for what exists but the caller may not see. */ NOT_FOUND: "NOT_FOUND", - /** Sent by the error middleware for any error that isn't an APIException. */ + CONFLICT: "CONFLICT", + RATE_LIMITED: "RATE_LIMITED", SERVER_ERROR: "SERVER_ERROR", - /** Nothing sends it. */ - TIMEOUT: "TIMEOUT", - /** - * Sent with 401 to a caller not signed in, and with 403 to one who may not. - */ - UNAUTHORIZED: "UNAUTHORIZED", - VALIDATION_ERROR: "VALIDATION_ERROR", + /** Answers when another site, such as Bandcamp or YouTube, failed us. */ + BAD_GATEWAY: "BAD_GATEWAY", } as const; /** Carried by the backend's `APIException`, and sent in an {@link APIError}. */ diff --git a/src/components/editor/Markdown.tsx b/src/components/editor/Markdown.tsx index 5f74915b..530ef20d 100644 --- a/src/components/editor/Markdown.tsx +++ b/src/components/editor/Markdown.tsx @@ -9,6 +9,7 @@ import { memo } from "react"; import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; /** Passed to {@link Markdown} by any component that shows a user's text. */ export interface MarkdownProps { @@ -121,6 +122,24 @@ function internalPath(href: string | undefined): string | null { type Components = React.ComponentProps["components"]; +const ATTACHMENT_PATH_RE = /\/api\/attachments\/[0-9a-f-]{36}$/; + +/** + * Renders an image of the text. An attachment gets the viewer's media token, + * which the server asks for when the text linking it is private; any other + * image is left as written. + */ +function MarkdownImage( + { src, alt }: { src?: string | Blob; alt?: string }, +): React.JSX.Element { + const { mediaToken } = useAuth(); + let url = typeof src === "string" ? src : undefined; + if (url && mediaToken && ATTACHMENT_PATH_RE.test(url)) { + url = `${url}?token=${encodeURIComponent(mediaToken)}`; + } + return {alt; +} + /** * Overrides react-markdown's elements. It is built once, since a new object on * each render would re-render every element. @@ -137,9 +156,7 @@ const MARKDOWN_COMPONENTS: Components = { ); }, - img: ({ src, alt }) => ( - {alt - ), + img: ({ src, alt }) => , // A wide GFM table scrolls inside its own box instead of the page. table: ({ children: tableChildren }) => (
diff --git a/src/locales/en.js b/src/locales/en.js index 7ed0d025..5785dcc8 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1 +1,5 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2ygf_L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Edit email\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"9xDZu_\":[\"Could not load.\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"AnRu_j\":[\"Cancel reply\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Registering…\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"EwVrWF\":[\"The dump couldn't be removed. Try again.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"O32mmL\":[\"Edit description\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Loading messages…\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rn6SBY\":[\"Unmute\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"uD0qXQ\":[\"Drop a file here\"],\"uISnsY\":[\"Could not delete\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"v-Wp--\":[\"Open source\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}")}; \ No newline at end of file +/*eslint-disable*/ module.exports = { + messages: JSON.parse( + '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"9xDZu_":["Could not load."],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cj24wt":["Registering…"],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"f_akpP":["Max 50 MB"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wbXKOv":["File too large (max 50 MB)."],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', + ), +}; diff --git a/src/locales/fr.js b/src/locales/fr.js index 677c544a..d7ef7dce 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1 +1,5 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2ygf_L\":[\"← Retour\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Modifier l'e-mail\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"9xDZu_\":[\"Impossible de charger.\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Position\"],\"AnRu_j\":[\"Annuler la réponse\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Inscription…\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"EwVrWF\":[\"Le dump n'a pas pu être retiré. Réessayez.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"O32mmL\":[\"Modifier la description\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Chargement des messages…\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"f_akpP\":[\"Max 50 Mo\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jbernk\":[\"Chargement du profil…\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pvnfJD\":[\"Sombre\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rn6SBY\":[\"Rétablir le son\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uISnsY\":[\"Suppression impossible\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"v-Wp--\":[\"Ouvrir la source\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"wbXKOv\":[\"Fichier trop volumineux (max 50 Mo).\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}")}; \ No newline at end of file +/*eslint-disable*/ module.exports = { + messages: JSON.parse( + '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"9xDZu_":["Impossible de charger."],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cj24wt":["Inscription…"],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"f_akpP":["Max 50 Mo"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wbXKOv":["Fichier trop volumineux (max 50 Mo)."],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', + ), +};