diff --git a/Dockerfile b/Dockerfile index f4ab20d3..c24e7df9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN deno install COPY index.html ./ COPY public/ ./public/ COPY src/ ./src/ +COPY shared/ ./shared/ # VITE_API_* are only needed when the API lives on a different host than the frontend. ARG VITE_API_PROTOCOL @@ -32,6 +33,7 @@ COPY deno.json deno.lock ./ RUN deno install COPY api/ ./api/ +COPY shared/ ./shared/ COPY --from=builder /app/dist/ ./dist/ # Persistent data: database and uploaded/generated files must be mounted as volumes. diff --git a/README.md b/README.md index 0977fc54..cb1c05a1 100644 --- a/README.md +++ b/README.md @@ -105,25 +105,32 @@ example.com { ## Project structure ``` +shared/ # Imported by both halves: API and socket types, limits, permissions + test/ # Tests of shared/ (Deno): test/.test.ts api/ main.ts # Entry point — Oak application, middleware, routes config.ts # Environment variables - middleware/ # errorMiddleware, authMiddleware - routes/ # HTTP routes + WebSocket - services/ # Business logic + middleware/ # Oak middleware: errors, auth, OG tags + routes/ # One router per URL prefix: HTTP + WebSocket, no SQL + services/ # Business logic and every SQL query + remote/ # Everything that fetches from other sites providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …) - model/ # Row types, type guards - lib/ # JWT, pagination, slugify, upload, static helpers, … + model/ # Per table: row type, row guard, row ↔ API mappers + 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() + 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 sql/ gerbeur.db # SQLite database (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) src/ # React frontend (Vite) - config/ # API base URL, validation constants, feed tabs, upload limits + config/ # API base URL, feed tabs, player mode pages/ # One component per route, nothing else components/ # React components only (.tsx) contexts/ # FooContext.ts + FooProvider.tsx pairs @@ -132,15 +139,29 @@ src/ # React frontend (Vite) test/ # Frontend tests (Vitest): test///.test.tsx locales/ # Lingui message catalogues (en, fr) themes/ # Per-theme CSS files - model.ts # Shared frontend types i18n.ts # Lingui runtime setup public/ # Static assets (favicon, manifest, service worker) ``` -Each of `components/`, `contexts/`, `hooks/` and `utils/` is split into the -same domain folders — `auth`, `category`, `chat`, `comment`, `dump`, `editor`, -`feed`, `form`, `layout`, `player`, `playlist`, `profile`, `social`, `theme`, -`ws` — plus `shared/` for what belongs to no single domain. So the chat lives in -`components/chat/`, `contexts/chat/`, `hooks/chat/` and `utils/chat/`, and its -tests in `test/`. Files are imported directly (no `index.ts` -barrels), and everything uses named exports. +Whatever crosses the wire is defined once, in `shared/`: the API envelope +(`shared/api.ts`), each domain's types and request bodies (`shared/dump.ts`, +`shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation +limits and the role permissions. The server sends Dates; the client reads +`Wire`, the same type with every Date an ISO string, and turns it back with +`src/utils/shared/deserialize.ts`. `shared/` imports nothing from either half. + +The backend is split by layer, one kind of module per folder, and each file is +named after its domain: the dumps are `routes/dumps.ts`, +`services/dump-service.ts`, `model/dump.ts` and `validators/dump.ts`. Routes +parse and answer, services hold the logic and the SQL, `remote/` is the only +place that fetches from other sites. Tests mirror the source one file per +module, so the tests of `services/chat-service.ts` are in +`test/services/chat-service.test.ts`. + +Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is +split into the same domain folders — `auth`, `category`, `chat`, `comment`, +`dump`, `editor`, `feed`, `form`, `layout`, `player`, `playlist`, `profile`, +`social`, `theme`, `ws` — plus `shared/` for what belongs to no single domain. +So the chat lives in `components/chat/`, `contexts/chat/`, `hooks/chat/` and +`utils/chat/`, and its tests in `test/`. Files are imported directly +(no `index.ts` barrels), and everything uses named exports. diff --git a/api/config.ts b/api/config.ts index 77a35312..99573caf 100644 --- a/api/config.ts +++ b/api/config.ts @@ -48,7 +48,6 @@ export const ALLOWED_IMAGE_MIMES = new Set([ "image/webp", ]); -export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB export const DUMP_ALLOWED_MIME_PREFIXES = [ "text/", "image/", @@ -76,22 +75,6 @@ export const PAGINATION_MAX_LIMIT = 100; export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; export const UNUSED_INVITES_RETENTION_DAYS = 7; -// Validation constraints -export const VALIDATION = { - USERNAME_MIN: 1, - USERNAME_MAX: 32, - PASSWORD_MIN: 8, - PASSWORD_MAX: 128, - DUMP_TITLE_MAX: 200, - DUMP_COMMENT_MAX: 5000, - PLAYLIST_TITLE_MAX: 100, - PLAYLIST_DESCRIPTION_MAX: 2000, - COMMENT_BODY_MAX: 5000, - USER_DESCRIPTION_MAX: 2000, - CATEGORY_NAME_MAX: 50, - CATEGORY_SLUG_MAX: 50, -} as const; - // SEO/OG export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; diff --git a/api/db/connection.ts b/api/db/connection.ts new file mode 100644 index 00000000..c078b082 --- /dev/null +++ b/api/db/connection.ts @@ -0,0 +1,36 @@ +import { DatabaseSync } from "node:sqlite"; +import { DB_PATH } from "../config.ts"; +import { runMigrations } from "./migrate.ts"; +import { runStartupTasks } from "./startup.ts"; + +export const db = new DatabaseSync(DB_PATH); +// WAL lets readers run alongside a writer, and with synchronous = NORMAL a +// commit no longer waits on an fsync (a crash can lose the last transactions, +// never corrupt the file). busy_timeout covers the init/migration scripts and +// the test harness opening the same file. +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 5000; + PRAGMA foreign_keys = ON; +`); +runMigrations(db); +await runStartupTasks(db); + +/** + * Runs `fn` inside one transaction: several writes commit together (and cost + * one commit rather than one each), and a throw rolls them all back. Nested + * calls join the outer transaction. + */ +export function transaction(fn: () => T): T { + if (db.isTransaction) return fn(); + db.exec("BEGIN;"); + try { + const result = fn(); + db.exec("COMMIT;"); + return result; + } catch (err) { + db.exec("ROLLBACK;"); + throw err; + } +} diff --git a/api/db/startup.ts b/api/db/startup.ts new file mode 100644 index 00000000..c89e3eff --- /dev/null +++ b/api/db/startup.ts @@ -0,0 +1,52 @@ +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"; + +/** + * Housekeeping run once when the database is opened, after the migrations: + * drops what has expired, and seeds the default admin on an empty instance. + */ +export async function runStartupTasks(db: DatabaseSync): Promise { + // Purge expired/used password reset tokens + db.prepare( + `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, + ).run(); + + // Purge expired unused invites + db.prepare( + `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, + ).run(); + + // Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour + 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 }[]; + 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(); + } + + // Create default admin user if no users exist + const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { + count: number; + }; + if (userCount.count === 0) { + 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); + console.log( + "Created default admin user (username: admin, password: admin)", + ); + } +} diff --git a/api/lib/api-exception.ts b/api/lib/api-exception.ts new file mode 100644 index 00000000..76c15f47 --- /dev/null +++ b/api/lib/api-exception.ts @@ -0,0 +1,13 @@ +import type { APIErrorCode } from "../../shared/api.ts"; + +/** Thrown anywhere below a route; errorMiddleware turns it into a response. */ +export class APIException extends Error { + readonly code: APIErrorCode; + readonly status: number; + + constructor(code: APIErrorCode, status: number, message: string) { + super(message); + this.code = code; + this.status = status; + } +} diff --git a/api/lib/auth.ts b/api/lib/auth.ts index b4a46a4a..973a7f4d 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -1,12 +1,10 @@ import type { Context } from "@oak/oak"; import { verifyJWT, verifyMediaToken } from "./jwt.ts"; -import { - APIErrorCode, - APIException, - type AuthPayload, - type Role, -} from "../model/interfaces.ts"; -import { db } from "../model/db.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"; +import { getSessionState } from "../services/user-service.ts"; /** * Checks a signature-verified token against the live user row: the user must @@ -21,19 +19,15 @@ function checkTokenOwner( userId: string, iat: number | undefined, ): { role: Role } | null { - const row = db.prepare( - `SELECT role, tokens_valid_after FROM users WHERE id = ?;`, - ).get(userId) as - | { role: Role; tokens_valid_after: string | null } - | undefined; - if (!row) return null; - if (row.tokens_valid_after) { + const state = getSessionState(userId); + if (!state) return null; + if (state.tokensValidAfter) { const cutoff = Math.floor( - new Date(row.tokens_valid_after).getTime() / 1000, + new Date(state.tokensValidAfter).getTime() / 1000, ); if ((iat ?? 0) < cutoff) return null; } - return { role: row.role }; + return { role: state.role }; } /** diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 4f22bba5..16df76e0 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -7,7 +7,7 @@ import { isAuthPayload, isPasswordResetPayload, type PasswordResetPayload, -} from "../model/interfaces.ts"; +} from "../model/auth.ts"; import { JWT_SECRET } from "../config.ts"; if (!JWT_SECRET) { diff --git a/api/lib/permissions.ts b/api/lib/permissions.ts index 66d723fb..3029b216 100644 --- a/api/lib/permissions.ts +++ b/api/lib/permissions.ts @@ -1,40 +1,5 @@ -import { type AuthPayload, type Role } from "../model/interfaces.ts"; - -// Named capabilities checked at enforcement points. Extend this union as new -// gated capabilities are added (e.g. "invite:manage"). -export type Permission = - | "dump:moderate" - | "comment:moderate" - | "playlist:moderate" - | "chat:moderate" - | "user:manage" - | "category:manage"; - -const ALL_PERMISSIONS: readonly Permission[] = [ - "dump:moderate", - "comment:moderate", - "playlist:moderate", - "chat:moderate", - "user:manage", - "category:manage", -]; - -// Roles are fixed in code. `admin` is a superset of every permission (current -// and future); `moderator` gets the content-moderation permissions only. -export const ROLE_PERMISSIONS: Record = { - admin: ALL_PERMISSIONS, - moderator: [ - "dump:moderate", - "comment:moderate", - "playlist:moderate", - "chat:moderate", - ], - user: [], -}; - -export function hasPermission(role: Role, permission: Permission): boolean { - return ROLE_PERMISSIONS[role].includes(permission); -} +import { hasPermission, type Permission } from "../../shared/permissions.ts"; +import type { AuthPayload } from "../model/auth.ts"; export function can(payload: AuthPayload, permission: Permission): boolean { return hasPermission(payload.role, permission); diff --git a/api/lib/playlist-members.ts b/api/lib/playlist-members.ts index bfa94418..a5aae7a4 100644 --- a/api/lib/playlist-members.ts +++ b/api/lib/playlist-members.ts @@ -1,4 +1,4 @@ -import type { Playlist } from "../model/interfaces.ts"; +import type { Playlist } from "../../shared/playlist.ts"; /** A playlist's dump, with what deciding who may see it takes. */ export interface PlaylistMember { diff --git a/api/lib/site-icons.ts b/api/lib/site-icons.ts index b7a94de6..79a7effa 100644 --- a/api/lib/site-icons.ts +++ b/api/lib/site-icons.ts @@ -7,9 +7,8 @@ // Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in // prod, public/ in dev) — see that script for how the output dir is chosen. import { initWasm, Resvg } from "@resvg/resvg-wasm"; +import { fetchTwemojiSvg } from "../remote/twemoji.ts"; -// Pinned Twemoji release. -const TWEMOJI_VERSION = "16.0.1"; const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512]; const APPLE_SIZE = 180; const ICO_SIZES = [16, 32, 48]; @@ -35,34 +34,6 @@ function ensureWasm(): Promise { return wasmReady; } -// Cache the fetched glyph per emoji so repeated runs don't re-hit the network. -const glyphCache = new Map(); - -async function fetchGlyph(emoji: string): Promise { - const cp = emojiToCodepoint(emoji); - const cached = glyphCache.get(cp); - if (cached) return cached; - const url = - `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`; - let res: Response; - try { - res = await fetch(url); - } catch (cause) { - throw new Error( - `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`, - { cause }, - ); - } - if (!res.ok) { - throw new Error( - `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`, - ); - } - const svg = await res.text(); - glyphCache.set(cp, svg); - return svg; -} - function renderPng(svg: string, size: number, background?: string): Uint8Array { const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, @@ -143,7 +114,7 @@ export async function generateSiteIcons( opts: GenerateSiteIconsOptions, ): Promise { const { emoji, outDir, siteName, bgColor } = opts; - const svg = await fetchGlyph(emoji); + const svg = await fetchTwemojiSvg(emoji, emojiToCodepoint(emoji)); await ensureWasm(); await Deno.mkdir(outDir, { recursive: true }); diff --git a/api/lib/upload.ts b/api/lib/upload.ts index e37d516a..bbe47818 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -1,5 +1,6 @@ import type { Context } from "@oak/oak"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "./api-exception.ts"; import { serveFile } from "./serve-file.ts"; import { ALLOWED_IMAGE_MIMES, diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 3bfc8877..acdb1303 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -5,12 +5,11 @@ import { type State, } from "@oak/oak"; import { authenticate } from "../lib/auth.ts"; -import { - APIErrorCode, - APIException, - type AuthPayload, -} from "../model/interfaces.ts"; -import { can, type Permission } from "../lib/permissions.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"; +import type { Permission } from "../../shared/permissions.ts"; export interface AuthState extends State { user: AuthPayload; diff --git a/api/middleware/error.ts b/api/middleware/error.ts index 0afabffc..0444065f 100644 --- a/api/middleware/error.ts +++ b/api/middleware/error.ts @@ -1,6 +1,7 @@ import { Context, Next } from "@oak/oak"; -import { APIErrorCode, APIException, APIFailure } from "../model/interfaces.ts"; +import { APIErrorCode, APIFailure } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; export async function errorMiddleware(ctx: Context, next: Next) { try { diff --git a/api/model/auth.ts b/api/model/auth.ts new file mode 100644 index 00000000..cac3a2bc --- /dev/null +++ b/api/model/auth.ts @@ -0,0 +1,36 @@ +import { isRole, type Role } from "../../shared/user.ts"; + +export interface AuthPayload { + userId: string; + username: string; + role: Role; + exp: number; + iat?: number; +} + +export function isAuthPayload(obj: unknown): obj is AuthPayload { + return !!obj && + typeof obj === "object" && + // Purpose-scoped tokens (password reset, media) are never session tokens. + !("purpose" in obj) && + "userId" in obj && typeof obj.userId === "string" && + "username" in obj && typeof obj.username === "string" && + "role" in obj && isRole(obj.role) && + "exp" in obj && typeof obj.exp === "number"; +} + +export interface PasswordResetPayload { + purpose: "password-reset"; + userId: string; + exp: number; +} + +export function isPasswordResetPayload( + obj: unknown, +): obj is PasswordResetPayload { + return !!obj && typeof obj === "object" && + "purpose" in obj && + (obj as Record).purpose === "password-reset" && + "userId" in obj && + typeof (obj as Record).userId === "string"; +} diff --git a/api/model/category.ts b/api/model/category.ts new file mode 100644 index 00000000..c3af3440 --- /dev/null +++ b/api/model/category.ts @@ -0,0 +1,34 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { Category } from "../../shared/category.ts"; + +export interface CategoryRow { + id: string; + slug: string; + name: string; + position: number; + created_at: string; + updated_at: string | null; + [key: string]: SQLOutputValue; +} + +export function isCategoryRow(obj: unknown): obj is CategoryRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "slug" in obj && typeof obj.slug === "string" && + "name" in obj && typeof obj.name === "string" && + "position" in obj && typeof obj.position === "number" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null); +} + +export function categoryRowToApi(row: CategoryRow): Category { + return { + id: row.id, + slug: row.slug, + name: row.name, + position: row.position, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + }; +} diff --git a/api/model/chat.ts b/api/model/chat.ts new file mode 100644 index 00000000..b6cb372b --- /dev/null +++ b/api/model/chat.ts @@ -0,0 +1,54 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { ChatMessage } from "../../shared/chat.ts"; + +export interface ChatMessageRow { + id: string; + user_id: string; + body: string; + created_at: string; + updated_at: string | null; + author_username: string; + author_avatar_mime: string | null; + // Reply columns: reply_to_id is the stored target; the author/body preview are + // LEFT-JOINed from the target and go null when there's no reply (or it was + // since deleted). + reply_to_id: string | null; + reply_to_author: string | null; + reply_to_body: string | null; + [key: string]: SQLOutputValue; +} + +export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "body" in obj && typeof obj.body === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "author_username" in obj && typeof obj.author_username === "string" && + "author_avatar_mime" in obj && + (typeof obj.author_avatar_mime === "string" || + obj.author_avatar_mime === null) && + "reply_to_id" in obj && + (typeof obj.reply_to_id === "string" || obj.reply_to_id === null) && + "reply_to_author" in obj && + (typeof obj.reply_to_author === "string" || obj.reply_to_author === null) && + "reply_to_body" in obj && + (typeof obj.reply_to_body === "string" || obj.reply_to_body === null); +} + +export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { + return { + id: row.id, + userId: row.user_id, + body: row.body, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + authorUsername: row.author_username, + authorAvatarMime: row.author_avatar_mime ?? undefined, + replyToId: row.reply_to_id ?? undefined, + replyToAuthor: row.reply_to_author ?? undefined, + replyToBody: row.reply_to_body ?? undefined, + }; +} diff --git a/api/model/comment.ts b/api/model/comment.ts new file mode 100644 index 00000000..08ac55d8 --- /dev/null +++ b/api/model/comment.ts @@ -0,0 +1,54 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { Comment } from "../../shared/comment.ts"; + +export interface CommentRow { + id: string; + dump_id: string; + user_id: string; + parent_id: string | null; + body: string; + created_at: string; + updated_at: string | null; + deleted: number; + like_count: number; + author_username: string; + author_avatar_mime: string | null; + [key: string]: SQLOutputValue; +} + +export function isCommentRow( + obj: unknown, +): obj is CommentRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "dump_id" in obj && typeof obj.dump_id === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "parent_id" in obj && + (typeof obj.parent_id === "string" || obj.parent_id === null) && + "body" in obj && typeof obj.body === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "deleted" in obj && typeof obj.deleted === "number" && + "like_count" in obj && typeof obj.like_count === "number" && + "author_username" in obj && typeof obj.author_username === "string" && + "author_avatar_mime" in obj && + (typeof obj.author_avatar_mime === "string" || + obj.author_avatar_mime === null); +} + +export function commentRowToApi(row: CommentRow): Comment { + return { + id: row.id, + dumpId: row.dump_id, + userId: row.user_id, + parentId: row.parent_id ?? undefined, + body: row.body, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + deleted: Boolean(row.deleted), + likeCount: row.like_count, + authorUsername: row.author_username, + authorAvatarMime: row.author_avatar_mime ?? undefined, + }; +} diff --git a/api/model/db.ts b/api/model/db.ts deleted file mode 100644 index 45539652..00000000 --- a/api/model/db.ts +++ /dev/null @@ -1,571 +0,0 @@ -import { randomBytes, scryptSync } from "node:crypto"; -import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; -import { - type Category, - type ChatMessage, - type Comment, - Dump, - isRole, - type Notification, - type NotificationData, - type NotificationType, - type Playlist, - type RichContent, - type Role, - type User, -} from "./interfaces.ts"; -import { - ATTACHMENTS_DIR, - DB_PATH, - ORPHANED_ATTACHMENTS_RETENTION_HOURS, - UNUSED_INVITES_RETENTION_DAYS, -} from "../config.ts"; -import { runMigrations } from "../db/migrate.ts"; - -export const db = new DatabaseSync(DB_PATH); -// WAL lets readers run alongside a writer, and with synchronous = NORMAL a -// commit no longer waits on an fsync (a crash can lose the last transactions, -// never corrupt the file). busy_timeout covers the init/migration scripts and -// the test harness opening the same file. -db.exec(` - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - PRAGMA busy_timeout = 5000; - PRAGMA foreign_keys = ON; -`); -runMigrations(db); - -/** - * Runs `fn` inside one transaction: several writes commit together (and cost - * one commit rather than one each), and a throw rolls them all back. Nested - * calls join the outer transaction. - */ -export function transaction(fn: () => T): T { - if (db.isTransaction) return fn(); - db.exec("BEGIN;"); - try { - const result = fn(); - db.exec("COMMIT;"); - return result; - } catch (err) { - db.exec("ROLLBACK;"); - throw err; - } -} - -// Purge expired/used password reset tokens on startup -db.prepare( - `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, -).run(); - -// Purge expired unused invites on startup -db.prepare( - `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, -).run(); - -// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour -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 }[]; -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(); -} - -// Create default admin user if no users exist -const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { - count: number; -}; -if (userCount.count === 0) { - 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); - console.log("Created default admin user (username: admin, password: admin)"); -} - -/** - * Database Row Types - */ - -export interface DumpRow { - id: string; - kind: string; - title: string; - comment: string | null; - user_id: string; - created_at: string; - updated_at: string | null; - slug: string | null; - url: string | null; - rich_content: string | null; - file_name: string | null; - file_mime: string | null; - file_size: number | null; - vote_count: number; - comment_count: number; - is_private: number; - custom_thumbnail_mime: string | null; - [key: string]: SQLOutputValue; // Index signature -} - -// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow above. -// Services import these instead of repeating the column list themselves. -export const DUMP_COLUMNS = - "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime"; - -export const DUMP_COLUMNS_ALIASED = - "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime"; - -export const DUMP_SELECT_COLUMNS = `${DUMP_COLUMNS}, - (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; - -export const DUMP_SELECT_COLUMNS_ALIASED = `${DUMP_COLUMNS_ALIASED}, - (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`; - -export interface UserRow { - id: string; - username: string; - password_hash: string; - role: Role; - created_at: string; - updated_at: string | null; - avatar_mime: string | null; - description: string | null; - invited_by: string | null; - // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by - invited_by_username: string | null; - email: string; - [key: string]: SQLOutputValue; // Index signature -} - -/** - * Type Guards - */ - -export function isDumpRow(obj: unknown): obj is DumpRow { - return !!obj && - typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "kind" in obj && typeof obj.kind === "string" && - "title" in obj && typeof obj.title === "string" && - "comment" in obj && - (typeof obj.comment === "string" || obj.comment === null) && - "user_id" in obj && typeof obj.user_id === "string" && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null) && - "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && - "url" in obj && (typeof obj.url === "string" || obj.url === null) && - "rich_content" in obj && - (typeof obj.rich_content === "string" || obj.rich_content === null) && - "file_name" in obj && - (typeof obj.file_name === "string" || obj.file_name === null) && - "file_mime" in obj && - (typeof obj.file_mime === "string" || obj.file_mime === null) && - "file_size" in obj && - (typeof obj.file_size === "number" || obj.file_size === null) && - "vote_count" in obj && typeof obj.vote_count === "number" && - "comment_count" in obj && typeof obj.comment_count === "number" && - "is_private" in obj && typeof obj.is_private === "number" && - "custom_thumbnail_mime" in obj && - (typeof obj.custom_thumbnail_mime === "string" || - obj.custom_thumbnail_mime === null); -} - -export function isUserRow(obj: unknown): obj is UserRow { - return !!obj && - typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "username" in obj && typeof obj.username === "string" && - "password_hash" in obj && typeof obj.password_hash === "string" && - "role" in obj && isRole(obj.role) && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null) && - "avatar_mime" in obj && - (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && - "description" in obj && - (typeof obj.description === "string" || obj.description === null) && - "invited_by" in obj && - (typeof obj.invited_by === "string" || obj.invited_by === null) && - "email" in obj && typeof obj.email === "string"; -} - -/** - * Conversion Helpers - */ - -export function dumpRowToApi(row: DumpRow): Dump { - return { - id: row.id, - kind: row.kind as "url" | "file", - title: row.title, - slug: row.slug ?? undefined, - url: row.url ?? undefined, - comment: row.comment ?? undefined, - userId: row.user_id, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - richContent: row.rich_content - ? (JSON.parse(row.rich_content) as RichContent) - : undefined, - fileName: row.file_name ?? undefined, - fileMime: row.file_mime ?? undefined, - fileSize: row.file_size ?? undefined, - voteCount: row.vote_count, - commentCount: row.comment_count, - isPrivate: Boolean(row.is_private), - thumbnailMime: row.custom_thumbnail_mime ?? undefined, - }; -} - -export function dumpApiToRow(dump: Dump): DumpRow { - return { - id: dump.id, - kind: dump.kind, - title: dump.title, - slug: dump.slug ?? null, - comment: dump.comment ?? null, - user_id: dump.userId, - created_at: dump.createdAt.toISOString(), - updated_at: dump.updatedAt?.toISOString() ?? null, - url: dump.url ?? null, - rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null, - file_name: dump.fileName ?? null, - file_mime: dump.fileMime ?? null, - file_size: dump.fileSize ?? null, - vote_count: dump.voteCount, - comment_count: dump.commentCount, - is_private: dump.isPrivate ? 1 : 0, - custom_thumbnail_mime: dump.thumbnailMime ?? null, - }; -} - -export function userRowToApi(row: UserRow): User { - return { - id: row.id, - username: row.username, - passwordHash: row.password_hash, - role: row.role, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - avatarMime: row.avatar_mime ?? undefined, - description: row.description ?? undefined, - invitedByUsername: typeof row.invited_by_username === "string" - ? row.invited_by_username - : undefined, - email: row.email, - }; -} - -export function userApiToRow(user: User): UserRow { - return { - id: user.id, - username: user.username, - password_hash: user.passwordHash, - role: user.role, - created_at: user.createdAt.toISOString(), - updated_at: user.updatedAt?.toISOString() ?? null, - avatar_mime: user.avatarMime ?? null, - description: user.description ?? null, - invited_by: null, - invited_by_username: null, - email: user.email, - }; -} - -export interface CommentRow { - id: string; - dump_id: string; - user_id: string; - parent_id: string | null; - body: string; - created_at: string; - updated_at: string | null; - deleted: number; - like_count: number; - author_username: string; - author_avatar_mime: string | null; - [key: string]: SQLOutputValue; -} - -export function isCommentRow( - obj: unknown, -): obj is CommentRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "dump_id" in obj && typeof obj.dump_id === "string" && - "user_id" in obj && typeof obj.user_id === "string" && - "parent_id" in obj && - (typeof obj.parent_id === "string" || obj.parent_id === null) && - "body" in obj && typeof obj.body === "string" && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null) && - "deleted" in obj && typeof obj.deleted === "number" && - "like_count" in obj && typeof obj.like_count === "number" && - "author_username" in obj && typeof obj.author_username === "string" && - "author_avatar_mime" in obj && - (typeof obj.author_avatar_mime === "string" || - obj.author_avatar_mime === null); -} - -export function commentRowToApi(row: CommentRow): Comment { - return { - id: row.id, - dumpId: row.dump_id, - userId: row.user_id, - parentId: row.parent_id ?? undefined, - body: row.body, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - deleted: Boolean(row.deleted), - likeCount: row.like_count, - authorUsername: row.author_username, - authorAvatarMime: row.author_avatar_mime ?? undefined, - }; -} - -export interface ChatMessageRow { - id: string; - user_id: string; - body: string; - created_at: string; - updated_at: string | null; - author_username: string; - author_avatar_mime: string | null; - // Reply columns: reply_to_id is the stored target; the author/body preview are - // LEFT-JOINed from the target and go null when there's no reply (or it was - // since deleted). - reply_to_id: string | null; - reply_to_author: string | null; - reply_to_body: string | null; - [key: string]: SQLOutputValue; -} - -export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "user_id" in obj && typeof obj.user_id === "string" && - "body" in obj && typeof obj.body === "string" && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null) && - "author_username" in obj && typeof obj.author_username === "string" && - "author_avatar_mime" in obj && - (typeof obj.author_avatar_mime === "string" || - obj.author_avatar_mime === null) && - "reply_to_id" in obj && - (typeof obj.reply_to_id === "string" || obj.reply_to_id === null) && - "reply_to_author" in obj && - (typeof obj.reply_to_author === "string" || obj.reply_to_author === null) && - "reply_to_body" in obj && - (typeof obj.reply_to_body === "string" || obj.reply_to_body === null); -} - -export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { - return { - id: row.id, - userId: row.user_id, - body: row.body, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - authorUsername: row.author_username, - authorAvatarMime: row.author_avatar_mime ?? undefined, - replyToId: row.reply_to_id ?? undefined, - replyToAuthor: row.reply_to_author ?? undefined, - replyToBody: row.reply_to_body ?? undefined, - }; -} - -export interface PlaylistRow { - id: string; - user_id: string; - title: string; - slug: string | null; - description: string | null; - is_public: number; - created_at: string; - updated_at: string | null; - image_mime: string | null; - [key: string]: SQLOutputValue; -} - -export function isPlaylistRow( - obj: unknown, -): obj is PlaylistRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "user_id" in obj && typeof obj.user_id === "string" && - "title" in obj && typeof obj.title === "string" && - "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && - "description" in obj && - (typeof obj.description === "string" || obj.description === null) && - "is_public" in obj && typeof obj.is_public === "number" && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null) && - "image_mime" in obj && - (typeof obj.image_mime === "string" || obj.image_mime === null); -} - -export function playlistRowToApi(row: PlaylistRow): Playlist { - return { - id: row.id, - userId: row.user_id, - title: row.title, - slug: row.slug ?? undefined, - description: row.description ?? undefined, - isPublic: Boolean(row.is_public), - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - imageMime: row.image_mime ?? undefined, - dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, - ownerUsername: typeof row.owner_username === "string" - ? row.owner_username - : undefined, - }; -} - -// ── Categories ──────────────────────────────────────────────────────────────── - -export interface CategoryRow { - id: string; - slug: string; - name: string; - position: number; - created_at: string; - updated_at: string | null; - [key: string]: SQLOutputValue; -} - -export function isCategoryRow(obj: unknown): obj is CategoryRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "slug" in obj && typeof obj.slug === "string" && - "name" in obj && typeof obj.name === "string" && - "position" in obj && typeof obj.position === "number" && - "created_at" in obj && typeof obj.created_at === "string" && - "updated_at" in obj && - (typeof obj.updated_at === "string" || obj.updated_at === null); -} - -export function categoryRowToApi(row: CategoryRow): Category { - return { - id: row.id, - slug: row.slug, - name: row.name, - position: row.position, - createdAt: new Date(row.created_at), - updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - }; -} - -export interface FollowRow { - id: string; - follower_id: string; - followed_user_id: string | null; - followed_playlist_id: string | null; - created_at: string; - [key: string]: SQLOutputValue; -} - -export function isFollowRow( - obj: unknown, -): obj is FollowRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "follower_id" in obj && typeof obj.follower_id === "string" && - "created_at" in obj && typeof obj.created_at === "string" && - "followed_user_id" in obj && (obj.followed_user_id === null || - typeof obj.followed_user_id === "string") && - "followed_playlist_id" in obj && (obj.followed_playlist_id === null || - typeof obj.followed_playlist_id === "string"); -} - -// ── Notifications ───────────────────────────────────────────────────────────── - -export interface NotificationRow { - id: string; - user_id: string; - type: string; - data: string; - read: number; - created_at: string; - source_key: string | null; - [key: string]: SQLOutputValue; -} - -export function isNotificationRow( - obj: unknown, -): obj is NotificationRow { - return !!obj && typeof obj === "object" && - "id" in obj && typeof obj.id === "string" && - "user_id" in obj && typeof obj.user_id === "string" && - "type" in obj && typeof obj.type === "string" && - "data" in obj && typeof obj.data === "string" && - "read" in obj && typeof obj.read === "number" && - "created_at" in obj && typeof obj.created_at === "string" && - "source_key" in obj && - (typeof obj.source_key === "string" || obj.source_key === null); -} - -export function notificationRowToApi(row: NotificationRow): Notification { - return { - id: row.id, - userId: row.user_id, - type: row.type as NotificationType, - data: JSON.parse(row.data) as NotificationData, - read: Boolean(row.read), - createdAt: new Date(row.created_at), - }; -} - -// ── Password reset tokens ───────────────────────────────────────────────────── - -export interface PasswordResetTokenRow { - token: string; - user_id: string; - expires_at: string; - used_at: string | null; - [key: string]: SQLOutputValue; -} - -export function isPasswordResetTokenRow( - obj: unknown, -): obj is PasswordResetTokenRow { - return !!obj && typeof obj === "object" && - "token" in obj && typeof obj.token === "string" && - "user_id" in obj && typeof obj.user_id === "string" && - "expires_at" in obj && typeof obj.expires_at === "string" && - "used_at" in obj && - (obj.used_at === null || typeof obj.used_at === "string"); -} - -// ── Invites ─────────────────────────────────────────────────────────────────── - -export interface InviteRow { - token: string; - inviter_id: string; - used_at: string | null; - created_at: string; - [key: string]: SQLOutputValue; -} - -export function isInviteRow( - obj: unknown, -): obj is InviteRow { - return !!obj && typeof obj === "object" && - "token" in obj && typeof obj.token === "string" && - "inviter_id" in obj && typeof obj.inviter_id === "string" && - "created_at" in obj && typeof obj.created_at === "string" && - "used_at" in obj && - (obj.used_at === null || typeof obj.used_at === "string"); -} diff --git a/api/model/dump.ts b/api/model/dump.ts new file mode 100644 index 00000000..cc47c58b --- /dev/null +++ b/api/model/dump.ts @@ -0,0 +1,113 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { Dump, RichContent } from "../../shared/dump.ts"; + +export interface DumpRow { + id: string; + kind: string; + title: string; + comment: string | null; + user_id: string; + created_at: string; + updated_at: string | null; + slug: string | null; + url: string | null; + rich_content: string | null; + file_name: string | null; + file_mime: string | null; + file_size: number | null; + vote_count: number; + comment_count: number; + is_private: number; + custom_thumbnail_mime: string | null; + [key: string]: SQLOutputValue; // Index signature +} + +// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow. +// Services import these instead of repeating the column list themselves. +export const DUMP_COLUMNS = + "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime"; + +export const DUMP_COLUMNS_ALIASED = + "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime"; + +export const DUMP_SELECT_COLUMNS = `${DUMP_COLUMNS}, + (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; + +export const DUMP_SELECT_COLUMNS_ALIASED = `${DUMP_COLUMNS_ALIASED}, + (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`; + +export function isDumpRow(obj: unknown): obj is DumpRow { + return !!obj && + typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "kind" in obj && typeof obj.kind === "string" && + "title" in obj && typeof obj.title === "string" && + "comment" in obj && + (typeof obj.comment === "string" || obj.comment === null) && + "user_id" in obj && typeof obj.user_id === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && + "url" in obj && (typeof obj.url === "string" || obj.url === null) && + "rich_content" in obj && + (typeof obj.rich_content === "string" || obj.rich_content === null) && + "file_name" in obj && + (typeof obj.file_name === "string" || obj.file_name === null) && + "file_mime" in obj && + (typeof obj.file_mime === "string" || obj.file_mime === null) && + "file_size" in obj && + (typeof obj.file_size === "number" || obj.file_size === null) && + "vote_count" in obj && typeof obj.vote_count === "number" && + "comment_count" in obj && typeof obj.comment_count === "number" && + "is_private" in obj && typeof obj.is_private === "number" && + "custom_thumbnail_mime" in obj && + (typeof obj.custom_thumbnail_mime === "string" || + obj.custom_thumbnail_mime === null); +} + +export function dumpRowToApi(row: DumpRow): Dump { + return { + id: row.id, + kind: row.kind as "url" | "file", + title: row.title, + slug: row.slug ?? undefined, + url: row.url ?? undefined, + comment: row.comment ?? undefined, + userId: row.user_id, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + richContent: row.rich_content + ? (JSON.parse(row.rich_content) as RichContent) + : undefined, + fileName: row.file_name ?? undefined, + fileMime: row.file_mime ?? undefined, + fileSize: row.file_size ?? undefined, + voteCount: row.vote_count, + commentCount: row.comment_count, + isPrivate: Boolean(row.is_private), + thumbnailMime: row.custom_thumbnail_mime ?? undefined, + }; +} + +export function dumpApiToRow(dump: Dump): DumpRow { + return { + id: dump.id, + kind: dump.kind, + title: dump.title, + slug: dump.slug ?? null, + comment: dump.comment ?? null, + user_id: dump.userId, + created_at: dump.createdAt.toISOString(), + updated_at: dump.updatedAt?.toISOString() ?? null, + url: dump.url ?? null, + rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null, + file_name: dump.fileName ?? null, + file_mime: dump.fileMime ?? null, + file_size: dump.fileSize ?? null, + vote_count: dump.voteCount, + comment_count: dump.commentCount, + is_private: dump.isPrivate ? 1 : 0, + custom_thumbnail_mime: dump.thumbnailMime ?? null, + }; +} diff --git a/api/model/follow.ts b/api/model/follow.ts new file mode 100644 index 00000000..98110877 --- /dev/null +++ b/api/model/follow.ts @@ -0,0 +1,23 @@ +import type { SQLOutputValue } from "node:sqlite"; + +export interface FollowRow { + id: string; + follower_id: string; + followed_user_id: string | null; + followed_playlist_id: string | null; + created_at: string; + [key: string]: SQLOutputValue; +} + +export function isFollowRow( + obj: unknown, +): obj is FollowRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "follower_id" in obj && typeof obj.follower_id === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "followed_user_id" in obj && (obj.followed_user_id === null || + typeof obj.followed_user_id === "string") && + "followed_playlist_id" in obj && (obj.followed_playlist_id === null || + typeof obj.followed_playlist_id === "string"); +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts deleted file mode 100644 index f1531af5..00000000 --- a/api/model/interfaces.ts +++ /dev/null @@ -1,1026 +0,0 @@ -import { VALIDATION } from "../config.ts"; - -/** - * Backend - */ - -export interface RichContent { - type: string; - url: string; - siteName?: string; - title?: string; - description?: string; - /** - * A real preview image (og:image, a large content image, a video still). - * Never a favicon — an absent value means "this page offers no artwork", - * which is what makes the generated placeholder possible. - */ - thumbnailUrl?: string; - /** The page's own icon, used as the placeholder's glyph. */ - faviconUrl?: string; - /** The page's declared brand color, normalized to `#rrggbb`. */ - accentColor?: string; - videoId?: string; - embedUrl?: string; -} - -export interface Dump { - id: string; - kind: "url" | "file"; - title: string; - slug?: string; - comment?: string; - userId: string; - createdAt: Date; - updatedAt?: Date; - url?: string; - richContent?: RichContent; - fileName?: string; - fileMime?: string; - fileSize?: number; - voteCount: number; - commentCount: number; - isPrivate: boolean; - thumbnailMime?: string; - categoryIds?: string[]; -} - -/** - * Categories - */ - -export interface Category { - id: string; - slug: string; - name: string; - position: number; - createdAt: Date; - updatedAt?: Date; -} - -export interface CreateCategoryRequest { - slug: string; - name: string; - position?: number; -} - -export interface UpdateCategoryRequest { - slug?: string; - name?: string; - position?: number; -} - -export function isCreateCategoryRequest( - obj: unknown, -): obj is CreateCategoryRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - if (typeof o.slug !== "string") return false; - if (typeof o.name !== "string") return false; - if ("position" in o && typeof o.position !== "number") return false; - return true; -} - -export function isUpdateCategoryRequest( - obj: unknown, -): obj is UpdateCategoryRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - if ("slug" in o && typeof o.slug !== "string") return false; - if ("name" in o && typeof o.name !== "string") return false; - if ("position" in o && typeof o.position !== "number") return false; - return true; -} - -/** - * Authentication - */ - -export type Role = "user" | "moderator" | "admin"; - -export const ROLES: readonly Role[] = ["user", "moderator", "admin"]; - -export function isRole(value: unknown): value is Role { - return typeof value === "string" && - (ROLES as readonly string[]).includes(value); -} - -export interface User { - id: string; - username: string; - passwordHash: string; - role: Role; - createdAt: Date; - updatedAt?: Date; - avatarMime?: string; - description?: string; - invitedByUsername?: string; - email: string; -} - -/** A user as anyone may see them: no password hash, no email. */ -export type PublicUser = Omit; - -/** A user as they see themselves: everything but the password hash. */ -export type SelfUser = Omit; - -export function toPublicUser( - { passwordHash: _, email: _email, ...user }: User, -): PublicUser { - return user; -} - -export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { - return user; -} - -/** A match for the @mention autocomplete. */ -export interface UserSuggestion { - id: string; - username: string; - avatarMime: string | null; -} - -/** Someone a user invited, directly or down the line. */ -export interface Invitee { - id: string; - username: string; - avatarMime?: string; - invitedById: string; - createdAt: Date; -} - -export interface LoginUserRequest { - username: string; - password: string; -} - -export interface RegisterUserRequest { - username: string; - password: string; - inviteToken: string; - email: string; -} - -export interface UpdateUserRequest { - username?: string; - password?: string; - role?: Role; - description?: string | null; - email?: string; -} - -export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { - return !!obj && typeof obj === "object" && - "username" in obj && typeof obj.username === "string" && - "password" in obj && typeof obj.password === "string"; -} - -/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */ -export function fieldOf(obj: unknown, key: string): unknown { - return obj !== null && typeof obj === "object" && key in obj - ? (obj as Record)[key] - : undefined; -} - -export function isRegisterUserRequest( - obj: unknown, -): obj is RegisterUserRequest { - return validateRegisterUserRequest(obj) === null; -} - -/** Returns a human-readable error string, or null if the request is valid. */ -export function validateRegisterUserRequest(obj: unknown): string | null { - if ( - !obj || typeof obj !== "object" || - !("username" in obj) || typeof obj.username !== "string" || - !("password" in obj) || typeof obj.password !== "string" || - !("inviteToken" in obj) || typeof obj.inviteToken !== "string" || - !("email" in obj) || typeof obj.email !== "string" - ) return "Invalid request"; - const { username, password, email } = obj as RegisterUserRequest; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return "Invalid email address"; - } - if ( - !new RegExp( - `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, - ) - .test(username) - ) { - return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`; - } - if (password.length < VALIDATION.PASSWORD_MIN) { - return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`; - } - if (password.length > VALIDATION.PASSWORD_MAX) { - return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`; - } - return null; -} - -export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - if ("username" in o) { - if (typeof o.username !== "string") return false; - if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false; - } - if ("password" in o) { - if (typeof o.password !== "string") return false; - const len = (o.password as string).length; - if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) { - return false; - } - } - if ("role" in o && !isRole(o.role)) return false; - if ( - "description" in o && typeof o.description !== "string" && - o.description !== null - ) return false; - if ("email" in o) { - if (typeof o.email !== "string") return false; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false; - } - if ( - typeof o.description === "string" && - (o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX - ) return false; - return true; -} - -/** The session token, plus the media-only one for file and thumbnail URLs. */ -export interface AuthTokens { - token: string; - mediaToken: string; -} - -export interface AuthResponse extends AuthTokens { - user: SelfUser; -} - -export type MediaTokenResponse = Pick; - -export interface InviteResponse { - token: string; -} - -export interface AttachmentResponse { - id: string; -} - -export interface AuthPayload { - userId: string; - username: string; - role: Role; - exp: number; - iat?: number; -} - -export function isAuthPayload(obj: unknown): obj is AuthPayload { - return !!obj && - typeof obj === "object" && - // Purpose-scoped tokens (password reset, media) are never session tokens. - !("purpose" in obj) && - "userId" in obj && typeof obj.userId === "string" && - "username" in obj && typeof obj.username === "string" && - "role" in obj && isRole(obj.role) && - "exp" in obj && typeof obj.exp === "number"; -} - -export interface PasswordResetPayload { - purpose: "password-reset"; - userId: string; - exp: number; -} - -export function isPasswordResetPayload( - obj: unknown, -): obj is PasswordResetPayload { - return !!obj && typeof obj === "object" && - "purpose" in obj && - (obj as Record).purpose === "password-reset" && - "userId" in obj && - typeof (obj as Record).userId === "string"; -} - -/** - * API - */ - -export enum APIErrorCode { - BAD_REQUEST = "BAD_REQUEST", - NOT_FOUND = "NOT_FOUND", - SERVER_ERROR = "SERVER_ERROR", - TIMEOUT = "TIMEOUT", - UNAUTHORIZED = "UNAUTHORIZED", - VALIDATION_ERROR = "VALIDATION_ERROR", -} - -export interface APIError { - code: APIErrorCode; - message: string; -} - -export interface APISuccess { - success: true; - data: T; - error?: never; -} - -export interface APIFailure { - success: false; - data?: never; - error: APIError; -} - -export type APIResponse = APISuccess | APIFailure; - -export interface PaginatedData { - items: T[]; - hasMore: boolean; - /** The whole list's size — only where it's shown (search results). */ - total?: number; -} - -export interface SearchResults { - dumps: PaginatedData; - users: PublicUser[]; - playlists: Playlist[]; -} - -/** - * `reached` is separate because a failed fetch still yields a usable stub - * (hostname only): without it the create form can't tell "this page has no - * preview" from "this link is dead". - */ -export interface LinkPreview { - reached: boolean; - richContent: RichContent | null; -} - -export class APIException extends Error { - readonly code: APIErrorCode; - readonly status: number; - - constructor(code: APIErrorCode, status: number, message: string) { - super(message); - this.code = code; - this.status = status; - } -} - -/** - * Comments - */ - -export interface Comment { - id: string; - dumpId: string; - userId: string; - parentId?: string; - body: string; - createdAt: Date; - updatedAt?: Date; - deleted: boolean; - likeCount: number; - authorUsername: string; - authorAvatarMime?: string; -} - -export interface CreateCommentRequest { - body: string; - parentId?: string; -} - -/** - * Chat - */ - -export interface ChatMessage { - id: string; - userId: string; - body: string; - createdAt: Date; - updatedAt?: Date; - authorUsername: string; - authorAvatarMime?: string; - /** Id of the message this one replies to, if any. Retained even when the - * target is later deleted (the preview fields below then go undefined). */ - replyToId?: string; - /** Author/snippet of the replied-to message, for a subtle inline reference. - * Absent when there's no reply, or the target has since been deleted. */ - replyToAuthor?: string; - replyToBody?: string; -} - -/** Wire format — createdAt arrives as an ISO string over JSON. */ -export type RawChatMessage = Omit & { - createdAt: string; -}; - -export function isCreateCommentRequest( - obj: unknown, -): obj is CreateCommentRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - return typeof o.body === "string" && - (o.body as string).trim().length > 0 && - (o.body as string).length <= VALIDATION.COMMENT_BODY_MAX && - (!("parentId" in o) || typeof o.parentId === "string" || - o.parentId === null); -} - -export interface UpdateCommentRequest { - body: string; -} - -export function isUpdateCommentRequest( - obj: unknown, -): obj is UpdateCommentRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - return typeof o.body === "string" && - (o.body as string).trim().length > 0 && - (o.body as string).length <= VALIDATION.COMMENT_BODY_MAX; -} - -/** - * Playlists - */ - -export interface Playlist { - id: string; - userId: string; - title: string; - slug?: string; - description?: string; - isPublic: boolean; - createdAt: Date; - updatedAt?: Date; - imageMime?: string; - dumpCount?: number; - ownerUsername?: string; -} - -export interface PlaylistWithDumps extends Playlist { - dumps: Dump[]; -} - -export interface PlaylistMembership { - playlist: Playlist; - hasDump: boolean; -} - -export interface CreatePlaylistRequest { - title: string; - description?: string; - isPublic: boolean; -} - -export interface UpdatePlaylistRequest { - title?: string; - description?: string | null; - isPublic?: boolean; -} - -export interface ReorderPlaylistRequest { - dumpIds: string[]; -} - -export function isCreatePlaylistRequest( - obj: unknown, -): obj is CreatePlaylistRequest { - if ( - !obj || typeof obj !== "object" || - !("title" in obj) || typeof obj.title !== "string" || - !("isPublic" in obj) || typeof obj.isPublic !== "boolean" - ) return false; - const o = obj as Record; - if ( - (o.title as string).length === 0 || - (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX - ) return false; - if ( - "description" in o && typeof o.description !== "string" && - o.description !== null - ) return false; - if ( - typeof o.description === "string" && - (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX - ) return false; - return true; -} - -export function isUpdatePlaylistRequest( - obj: unknown, -): obj is UpdatePlaylistRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - if ("title" in o) { - if (typeof o.title !== "string") return false; - if ( - (o.title as string).length === 0 || - (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX - ) return false; - } - if ( - "description" in o && typeof o.description !== "string" && - o.description !== null - ) return false; - if ( - typeof o.description === "string" && - (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX - ) return false; - if ("isPublic" in o && typeof o.isPublic !== "boolean") return false; - return true; -} - -export function isReorderPlaylistRequest( - obj: unknown, -): obj is ReorderPlaylistRequest { - return !!obj && typeof obj === "object" && - "dumpIds" in obj && Array.isArray(obj.dumpIds) && - (obj.dumpIds as unknown[]).every((id) => typeof id === "string"); -} - -/** - * Request DTOs - */ - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((v) => typeof v === "string"); -} - -export interface CreateUrlDumpRequest { - url: string; - /** Overrides the title scraped from the page — the poster edited the preview. */ - title?: string; - comment?: string; - isPrivate?: boolean; - categoryIds?: string[]; -} - -/** - * An existing dump pointing at the same URL, as shown by the create form's - * duplicate hint. Display-only projection — see `findDumpsByUrl`. - */ -export interface DumpUrlMatch { - id: string; - slug?: string; - title: string; - username: string; - createdAt: Date; - voteCount: number; - commentCount: number; -} - -export function isCreateUrlDumpRequest( - obj: unknown, -): obj is CreateUrlDumpRequest { - if ( - !obj || typeof obj !== "object" || - !("url" in obj) || typeof obj.url !== "string" - ) return false; - const o = obj as Record; - if ( - "comment" in o && typeof o.comment !== "string" && o.comment !== null - ) return false; - if ( - typeof o.comment === "string" && - (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX - ) return false; - if ("title" in o && typeof o.title !== "string" && o.title !== null) { - return false; - } - if ( - typeof o.title === "string" && - (o.title as string).length > VALIDATION.DUMP_TITLE_MAX - ) return false; - if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; - if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; - return true; -} - -export interface UpdateDumpRequest { - url?: string; - title?: string; - comment?: string; - isPrivate?: boolean; - categoryIds?: string[]; -} - -export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - if ("url" in o && typeof o.url !== "string" && o.url !== null) return false; - if ("title" in o) { - if (typeof o.title !== "string") return false; - const trimmed = (o.title as string).trim(); - if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return false; - } - if ( - "comment" in o && typeof o.comment !== "string" && o.comment !== null - ) return false; - if ( - typeof o.comment === "string" && - (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX - ) return false; - if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; - if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; - return true; -} - -/** - * WebSockets - */ - -// ── Client → Server ────────────────────────────────────────────────────────── - -export interface PingMessage { - type: "ping"; -} - -export interface PongMessage { - type: "pong"; -} - -export interface VoteCastMessage { - type: "vote_cast"; - dumpId: string; -} - -export interface VoteRemoveMessage { - type: "vote_remove"; - dumpId: string; -} - -export interface CommentLikeCastMessage { - type: "comment_like_cast"; - commentId: string; -} - -export interface CommentLikeRemoveMessage { - type: "comment_like_remove"; - commentId: string; -} - -export interface ChatSendMessage { - type: "chat_send"; - body: string; - /** Id of the message being replied to, if this is a reply. */ - replyToId?: string; - /** Client-chosen id echoed on the broadcast (or on the error), so the - * sender can match the outcome to its pending message. */ - nonce?: string; -} - -// Sent while the user is composing (true) and when they stop (false). The -// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate. -export interface ChatTypingMessage { - type: "chat_typing"; - typing: boolean; -} - -// Tells the server whether this client currently has the chatbox open, so that -// mention notifications can be skipped for users who are already reading chat. -export interface ChatFocusMessage { - type: "chat_focus"; - open: boolean; -} - -export interface ChatEditMessage { - type: "chat_edit"; - id: string; - body: string; -} - -export interface ChatDeleteMessage { - type: "chat_delete"; - id: string; -} - -// The user has seen the chat up to the message created at `at`. -export interface ChatReadMessage { - type: "chat_read"; - at: string; -} - -export type ClientToServerMessage = - | PingMessage - | PongMessage - | VoteCastMessage - | VoteRemoveMessage - | CommentLikeCastMessage - | CommentLikeRemoveMessage - | ChatSendMessage - | ChatTypingMessage - | ChatFocusMessage - | ChatEditMessage - | ChatDeleteMessage - | ChatReadMessage; - -// ── Server → Client ────────────────────────────────────────────────────────── - -export interface OnlineUser { - userId: string; - username: string; - hasAvatar: boolean; - avatarVersion?: number; -} - -export interface WelcomeMessage { - type: "welcome"; - users: OnlineUser[]; - myVotes: string[]; - myCommentLikes: string[]; - unreadNotificationCount: number; - /** The user's chat read marker and the unread count behind it (signed-in - * users only). */ - chatReadAt?: string; - unreadChatCount: number; -} - -export interface PresenceUpdateMessage { - type: "presence_update"; - users: OnlineUser[]; -} - -export interface VotesUpdateMessage { - type: "votes_update"; - dumpId: string; - voteCount: number; - voterId: string; - action: "cast" | "remove"; -} - -export interface VoteAckMessage { - type: "vote_ack"; - dumpId: string; - action: "cast" | "remove"; - voteCount: number; -} - -export interface CommentLikesUpdateMessage { - type: "comment_likes_update"; - commentId: string; - likeCount: number; - likerId: string; - action: "cast" | "remove"; -} - -export interface CommentLikeAckMessage { - type: "comment_like_ack"; - commentId: string; - action: "cast" | "remove"; - likeCount: number; -} - -export interface DumpCreatedMessage { - type: "dump_created"; - dump: Dump; -} - -export interface DumpUpdatedMessage { - type: "dump_updated"; - dump: Dump; -} - -export interface DumpDeletedMessage { - type: "dump_deleted"; - dumpId: string; -} - -export interface PlaylistCreatedMessage { - type: "playlist_created"; - playlist: Playlist; -} - -export interface PlaylistUpdatedMessage { - type: "playlist_updated"; - playlist: Playlist; -} - -export interface PlaylistDeletedMessage { - type: "playlist_deleted"; - playlistId: string; - userId: string; -} - -export interface PlaylistDumpsUpdatedMessage { - type: "playlist_dumps_updated"; - playlistId: string; - /** The members this recipient can see, in order. */ - dumpIds: string[]; - /** Every member, as Playlist.dumpCount counts them. */ - dumpCount: number; -} - -export interface UserUpdatedMessage { - type: "user_updated"; - user: PublicUser; -} - -export interface CommentCreatedMessage { - type: "comment_created"; - comment: Comment; -} - -export interface CommentUpdatedMessage { - type: "comment_updated"; - comment: Comment; -} - -export interface CommentDeletedMessage { - type: "comment_deleted"; - commentId: string; - dumpId: string; -} - -export interface NotificationCreatedMessage { - type: "notification_created"; - notification: RawNotification; -} - -export interface ErrorMessage { - type: "error"; - message?: string; - // Context for optimistic-action failures so the client can revert the exact - // pending vote/like immediately instead of waiting for its ACK timeout. - dumpId?: string; - commentId?: string; - action?: "cast" | "remove"; - /** Set when a chat action failed, so the client can surface it in the chat. */ - scope?: "chat"; - /** Echo of the failed chat_send's nonce. */ - nonce?: string; -} - -export interface ForceLogoutMessage { - type: "force_logout"; -} - -export interface ChatMessageMessage { - type: "chat_message"; - message: ChatMessage; - /** The sender's nonce, so its pending copy can be retired. */ - nonce?: string; -} - -export interface ChatMessageUpdatedMessage { - type: "chat_message_updated"; - message: ChatMessage; -} - -export interface ChatMessageDeletedMessage { - type: "chat_message_deleted"; - id: string; -} - -// The current set of users composing a chat message. Reuses OnlineUser so the -// client can render the same avatars as the presence row. -export interface ChatTypingUpdateMessage { - type: "chat_typing_update"; - users: OnlineUser[]; -} - -// The user's read marker moved (from any of their tabs or devices). -export interface ChatReadUpdateMessage { - type: "chat_read_update"; - readAt: string; - unread: number; -} - -export type ServerToClientMessage = - | PingMessage - | PongMessage - | WelcomeMessage - | PresenceUpdateMessage - | VotesUpdateMessage - | VoteAckMessage - | CommentLikesUpdateMessage - | CommentLikeAckMessage - | DumpCreatedMessage - | DumpUpdatedMessage - | DumpDeletedMessage - | PlaylistCreatedMessage - | PlaylistUpdatedMessage - | PlaylistDeletedMessage - | PlaylistDumpsUpdatedMessage - | UserUpdatedMessage - | CommentCreatedMessage - | CommentUpdatedMessage - | CommentDeletedMessage - | NotificationCreatedMessage - | ErrorMessage - | ForceLogoutMessage - | ChatMessageMessage - | ChatMessageUpdatedMessage - | ChatMessageDeletedMessage - | ChatTypingUpdateMessage - | ChatReadUpdateMessage; - -/** - * Follows - */ - -export interface FollowStatus { - followedUserIds: string[]; - followedPlaylistIds: string[]; -} - -/** - * Notifications - */ - -export type NotificationType = - | "playlist_followed" - | "user_followed" - | "user_dump_posted" - | "playlist_dump_added" - | "dump_upvoted" - | "user_mentioned" - | "dump_commented" - | "comment_liked"; - -export interface PlaylistFollowedData { - followerId: string; - followerUsername: string; - playlistId: string; - playlistTitle: string; -} - -export interface UserFollowedData { - followerId: string; - followerUsername: string; -} - -export interface UserDumpPostedData { - dumperId: string; - dumperUsername: string; - dumpId: string; - dumpTitle: string; -} - -export interface PlaylistDumpAddedData { - dumpId: string; - dumpTitle: string; - playlistId: string; - playlistTitle: string; -} - -export interface DumpUpvotedData { - voterId: string; - voterUsername: string; - dumpId: string; - dumpTitle: string; -} - -export interface UserMentionedData { - mentionerId: string; - mentionerUsername: string; - contextType: "comment" | "dump" | "playlist" | "chat"; - contextId: string; - contextTitle: string; - dumpId?: string; -} - -export interface DumpCommentedData { - commenterId: string; - commenterUsername: string; - commentId: string; - dumpId: string; - dumpTitle: string; -} - -export interface CommentLikedData { - likerId: string; - likerUsername: string; - commentId: string; - dumpId: string; - dumpTitle: string; -} - -export type NotificationData = - | PlaylistFollowedData - | UserFollowedData - | UserDumpPostedData - | PlaylistDumpAddedData - | DumpUpvotedData - | UserMentionedData - | DumpCommentedData - | CommentLikedData; - -export interface Notification { - id: string; - userId: string; - type: NotificationType; - data: NotificationData; - read: boolean; - createdAt: Date; -} - -/** Wire format — createdAt arrives as an ISO string over JSON. */ -export type RawNotification = Omit & { - createdAt: string; -}; diff --git a/api/model/invite.ts b/api/model/invite.ts new file mode 100644 index 00000000..6865e693 --- /dev/null +++ b/api/model/invite.ts @@ -0,0 +1,20 @@ +import type { SQLOutputValue } from "node:sqlite"; + +export interface InviteRow { + token: string; + inviter_id: string; + used_at: string | null; + created_at: string; + [key: string]: SQLOutputValue; +} + +export function isInviteRow( + obj: unknown, +): obj is InviteRow { + return !!obj && typeof obj === "object" && + "token" in obj && typeof obj.token === "string" && + "inviter_id" in obj && typeof obj.inviter_id === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "used_at" in obj && + (obj.used_at === null || typeof obj.used_at === "string"); +} diff --git a/api/model/notification.ts b/api/model/notification.ts new file mode 100644 index 00000000..ea944127 --- /dev/null +++ b/api/model/notification.ts @@ -0,0 +1,42 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { + Notification, + NotificationData, + NotificationType, +} from "../../shared/notification.ts"; + +export interface NotificationRow { + id: string; + user_id: string; + type: string; + data: string; + read: number; + created_at: string; + source_key: string | null; + [key: string]: SQLOutputValue; +} + +export function isNotificationRow( + obj: unknown, +): obj is NotificationRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "type" in obj && typeof obj.type === "string" && + "data" in obj && typeof obj.data === "string" && + "read" in obj && typeof obj.read === "number" && + "created_at" in obj && typeof obj.created_at === "string" && + "source_key" in obj && + (typeof obj.source_key === "string" || obj.source_key === null); +} + +export function notificationRowToApi(row: NotificationRow): Notification { + return { + id: row.id, + userId: row.user_id, + type: row.type as NotificationType, + data: JSON.parse(row.data) as NotificationData, + read: Boolean(row.read), + createdAt: new Date(row.created_at), + }; +} diff --git a/api/model/password-reset.ts b/api/model/password-reset.ts new file mode 100644 index 00000000..e3f9f15a --- /dev/null +++ b/api/model/password-reset.ts @@ -0,0 +1,20 @@ +import type { SQLOutputValue } from "node:sqlite"; + +export interface PasswordResetTokenRow { + token: string; + user_id: string; + expires_at: string; + used_at: string | null; + [key: string]: SQLOutputValue; +} + +export function isPasswordResetTokenRow( + obj: unknown, +): obj is PasswordResetTokenRow { + return !!obj && typeof obj === "object" && + "token" in obj && typeof obj.token === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "expires_at" in obj && typeof obj.expires_at === "string" && + "used_at" in obj && + (obj.used_at === null || typeof obj.used_at === "string"); +} diff --git a/api/model/playlist.ts b/api/model/playlist.ts new file mode 100644 index 00000000..919d1ad0 --- /dev/null +++ b/api/model/playlist.ts @@ -0,0 +1,51 @@ +import type { SQLOutputValue } from "node:sqlite"; +import type { Playlist } from "../../shared/playlist.ts"; + +export interface PlaylistRow { + id: string; + user_id: string; + title: string; + slug: string | null; + description: string | null; + is_public: number; + created_at: string; + updated_at: string | null; + image_mime: string | null; + [key: string]: SQLOutputValue; +} + +export function isPlaylistRow( + obj: unknown, +): obj is PlaylistRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "title" in obj && typeof obj.title === "string" && + "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && + "description" in obj && + (typeof obj.description === "string" || obj.description === null) && + "is_public" in obj && typeof obj.is_public === "number" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "image_mime" in obj && + (typeof obj.image_mime === "string" || obj.image_mime === null); +} + +export function playlistRowToApi(row: PlaylistRow): Playlist { + return { + id: row.id, + userId: row.user_id, + title: row.title, + slug: row.slug ?? undefined, + description: row.description ?? undefined, + isPublic: Boolean(row.is_public), + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + imageMime: row.image_mime ?? undefined, + dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, + ownerUsername: typeof row.owner_username === "string" + ? row.owner_username + : undefined, + }; +} diff --git a/api/model/user.ts b/api/model/user.ts new file mode 100644 index 00000000..372af415 --- /dev/null +++ b/api/model/user.ts @@ -0,0 +1,90 @@ +import type { SQLOutputValue } from "node:sqlite"; +import { + isRole, + type PublicUser, + type Role, + type SelfUser, +} from "../../shared/user.ts"; + +/** A user as stored: what they see of themselves, plus the password hash. */ +export interface User extends SelfUser { + passwordHash: string; +} + +export function toPublicUser( + { passwordHash: _, email: _email, ...user }: User, +): PublicUser { + return user; +} + +export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { + return user; +} + +export interface UserRow { + id: string; + username: string; + password_hash: string; + role: Role; + created_at: string; + updated_at: string | null; + avatar_mime: string | null; + description: string | null; + invited_by: string | null; + // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by + invited_by_username: string | null; + email: string; + [key: string]: SQLOutputValue; // Index signature +} + +export function isUserRow(obj: unknown): obj is UserRow { + return !!obj && + typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "username" in obj && typeof obj.username === "string" && + "password_hash" in obj && typeof obj.password_hash === "string" && + "role" in obj && isRole(obj.role) && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "avatar_mime" in obj && + (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && + "description" in obj && + (typeof obj.description === "string" || obj.description === null) && + "invited_by" in obj && + (typeof obj.invited_by === "string" || obj.invited_by === null) && + "email" in obj && typeof obj.email === "string"; +} + +export function userRowToApi(row: UserRow): User { + return { + id: row.id, + username: row.username, + passwordHash: row.password_hash, + role: row.role, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + avatarMime: row.avatar_mime ?? undefined, + description: row.description ?? undefined, + invitedByUsername: typeof row.invited_by_username === "string" + ? row.invited_by_username + : undefined, + email: row.email, + }; +} + +export function userApiToRow(user: User): UserRow { + return { + id: user.id, + username: user.username, + password_hash: user.passwordHash, + role: user.role, + created_at: user.createdAt.toISOString(), + updated_at: user.updatedAt?.toISOString() ?? null, + avatar_mime: user.avatarMime ?? null, + description: user.description ?? null, + invited_by: null, + invited_by_username: null, + email: user.email, + }; +} diff --git a/api/services/bandcamp-stream-service.ts b/api/remote/bandcamp-streams.ts similarity index 92% rename from api/services/bandcamp-stream-service.ts rename to api/remote/bandcamp-streams.ts index e8fed23e..e1d17d7d 100644 --- a/api/services/bandcamp-stream-service.ts +++ b/api/remote/bandcamp-streams.ts @@ -1,14 +1,8 @@ -import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { - fetchWithTimeout, - isValidHttpUrl, - readTextCapped, -} from "./rich-content-service.ts"; -import { - BANDCAMP_HOST_RE, - parseTralbum, - type Tralbum, -} from "./bandcamp-tralbum.ts"; +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"; +import type { Tralbum } from "../../shared/player.ts"; /** * Resolves a Bandcamp page to its streamable tracks, at play time. diff --git a/api/services/bandcamp-tralbum.ts b/api/remote/bandcamp-tralbum.ts similarity index 83% rename from api/services/bandcamp-tralbum.ts rename to api/remote/bandcamp-tralbum.ts index 2829e8a7..5a12f880 100644 --- a/api/services/bandcamp-tralbum.ts +++ b/api/remote/bandcamp-tralbum.ts @@ -15,42 +15,17 @@ * which callers turn into a fallback to the iframe embed. */ +import type { Tralbum, TralbumTrack } from "../../shared/player.ts"; + /** Hostnames Bandcamp serves its own sites from. Anchored at the end so that * `bandcamp.com.example.org` cannot pass. Artists on custom domains do not * match this and keep using the embed. */ export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/; -export interface TralbumTrack { - /** Position in the parsed array — the stable handle used to re-resolve a - * track after its signed URL expires. */ - index: number; - trackNum?: number; - title: string; - /** Seconds, fractional. */ - duration?: number; - streamUrl: string | null; - /** False for preorder, unreleased and still-encoding tracks. They stay in - * the list so numbering matches the release, but never enter the queue. */ - streamable: boolean; - /** Bandcamp caps free plays per track; a capped track may serve a clip. */ - capped: boolean; -} - -export interface Tralbum { - sourceUrl: string; - itemType: "album" | "track"; - title?: string; - artist?: string; - artworkUrl?: string; - tracks: TralbumTrack[]; - /** Epoch ms of the fetch that produced these signed URLs. */ - resolvedAt: number; -} - /** * Decode HTML entities in a single pass. * - * The single pass is the point: `decodeHtmlEntities` in rich-content-service.ts + * The single pass is the point: `decodeHtmlEntities` in remote/html.ts * replaces `&` before `"`, so an escaped-ampersand-then-quot sequence * (`&quot;`, i.e. the literal text `"` inside a title) decodes twice * and yields a bare `"` — which lands inside the JSON string and breaks the diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts new file mode 100644 index 00000000..98e99662 --- /dev/null +++ b/api/remote/fetch.ts @@ -0,0 +1,241 @@ +import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts"; +import { isSelfUrl } from "../lib/self-url.ts"; + +const FETCH_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", +}; + +const MAX_REDIRECTS = 5; + +/** + * Fetch `url` via a `curl --insecure` subprocess and return a minimal Response. + * Used as a last resort when Deno's rustls rejects the server's TLS certificate + * due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). + * Returns null if curl is unavailable or exits non-zero. + * + * `address` pins the connection to the IP already checked by + * resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes + * back with its Location so fetchWithTimeout can check the next hop too. + */ +async function fetchViaCurl( + url: URL, + address: string, + timeoutMs: number, +): Promise { + const tmpPath = await Deno.makeTempFile(); + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + const pinned = address.includes(":") ? `[${address}]` : address; + try { + const { code, stdout } = await new Deno.Command("curl", { + args: [ + "--silent", + "--insecure", + "--proto", + "=http,https", + "--resolve", + `${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`, + "--max-time", + String(Math.ceil(timeoutMs / 1000)), + "--max-filesize", + String(2 * MAX_PAGE_BYTES), + "--user-agent", + FETCH_HEADERS["User-Agent"], + "--header", + `Accept: ${FETCH_HEADERS["Accept"]}`, + "--header", + `Accept-Language: ${FETCH_HEADERS["Accept-Language"]}`, + "--output", + tmpPath, + "--write-out", + "%{http_code}\n%{redirect_url}\n%{content_type}", + url.href, + ], + stdout: "piped", + stderr: "null", + }).output(); + + if (code !== 0) return null; + + const [status, location, contentType = ""] = new TextDecoder() + .decode(stdout).split("\n").map((l) => l.trim()); + const statusCode = Number(status); + if (!(statusCode >= 200 && statusCode <= 599)) return null; + const headers = new Headers(); + if (location) headers.set("location", location); + if (contentType) headers.set("content-type", contentType); + + const bytes = await Deno.readFile(tmpPath); + if (!contentType.startsWith("text/")) { + return new Response(bytes, { status: statusCode, headers }); + } + // Decode using the charset declared in the Content-Type header so that + // pages served in ISO-8859-1, windows-1252, etc. are read correctly. + const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; + let bodyText: string; + try { + bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes); + } catch { + bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } + headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8")); + return new Response(bodyText, { status: statusCode, headers }); + } catch { + return null; + } finally { + await Deno.remove(tmpPath).catch(() => {}); + } +} + +/** + * Most bytes of a fetched page that are read. Metadata lives in the , + * and a "page" past this is a download, not something to scrape. + */ +export const MAX_PAGE_BYTES = 4 * 1024 * 1024; + +/** + * Hands `body` on, and clears the request's deadline once it has been read to + * the end or cancelled. Until then the deadline still applies: a server that + * answers at once and then drips its body can't hold the request open. + */ +function withDeadline( + body: ReadableStream, + timer: ReturnType, +): ReadableStream { + const reader = body.getReader(); + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + clearTimeout(timer); + controller.close(); + } else { + controller.enqueue(value); + } + } catch (err) { + clearTimeout(timer); + controller.error(err); + } + }, + cancel(reason) { + clearTimeout(timer); + return reader.cancel(reason); + }, + }); +} + +/** + * Reads a response's body as text, stopping at `maxBytes` (the rest is + * cancelled, never downloaded). + */ +export async function readTextCapped( + res: Response, + maxBytes = MAX_PAGE_BYTES, +): Promise { + if (!res.body) return ""; + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (size < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.byteLength; + } + } finally { + await reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(Math.min(size, maxBytes)); + let offset = 0; + for (const chunk of chunks) { + const part = chunk.subarray(0, bytes.byteLength - offset); + bytes.set(part, offset); + offset += part.byteLength; + if (offset >= bytes.byteLength) break; + } + return new TextDecoder().decode(bytes); +} + +/** One request, no redirects followed. The timeout covers the body too. */ +async function fetchOnce( + url: URL, + address: string, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res: Response; + try { + res = await fetch(url, { + signal: controller.signal, + headers: FETCH_HEADERS, + redirect: "manual", + }); + } catch (err) { + clearTimeout(timer); + if (!(err instanceof TypeError && err.message.includes("certificate"))) { + throw err; + } + + // Deno/rustls rejected the server's TLS certificate (expired, self-signed, + // or an unsupported signature algorithm) and offers no way to override + // verification programmatically. Fall back to a `curl --insecure` + // subprocess, which uses its own TLS stack. + const curlRes = await fetchViaCurl(url, address, timeoutMs); + if (curlRes) return curlRes; + + throw err; + } + if (!res.body) { + clearTimeout(timer); + return res; + } + return new Response(withDeadline(res.body, timer), { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); +} + +/** + * Fetch a user-supplied URL. Every hop — the URL itself and each redirect — + * must resolve to a public address (see resolvePublicAddress), so a link or a + * redirect can't point the server at its own network. + */ +export async function fetchWithTimeout( + url: string, + timeoutMs = 5000, +): Promise { + let current = new URL(url); + for (let hop = 0;; hop++) { + if (current.protocol !== "http:" && current.protocol !== "https:") { + throw new TypeError(`Unsupported protocol ${current.protocol}`); + } + const address = await resolvePublicAddress(current); + const res = await fetchOnce(current, address, timeoutMs); + const location = res.headers.get("location"); + if (res.status < 300 || res.status >= 400 || !location) return res; + + await res.body?.cancel(); + if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects"); + current = new URL(location, current); + } +} + +export function isValidHttpUrl(raw: string): boolean { + try { + const u = new URL(raw); + if (u.protocol !== "http:" && u.protocol !== "https:") return false; + // This site's own pages are fine even on a private host (e.g. localhost in + // development): selfProvider reads them from the DB, no HTTP involved. + // Every actual fetch is checked again, after DNS, in fetchWithTimeout. + return !isPrivateHostname(u) || isSelfUrl(u); + } catch { + return false; + } +} diff --git a/api/remote/html.ts b/api/remote/html.ts new file mode 100644 index 00000000..29a5e5d9 --- /dev/null +++ b/api/remote/html.ts @@ -0,0 +1,403 @@ +function decodeHtmlEntities(str: string): string { + return str + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))) + .replace( + /&#x([0-9a-f]+);/gi, + (_, hex) => String.fromCodePoint(parseInt(hex, 16)), + ); +} + +/** + * Find the first `` tag whose `attr` equals `value` and return its + * `content` (either attribute ordering). Each tag is matched in isolation so + * the `content` capture can never span across tag boundaries. + */ +function extractMetaContent( + html: string, + attr: string, + value: string, +): string | undefined { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i"); + const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; + const tagRe = /]*>/gi; + let m: RegExpExecArray | null; + while ((m = tagRe.exec(html)) !== null) { + const tag = m[0]; + if (!keyRe.test(tag)) continue; + const content = contentRe.exec(tag); + if (content) return decodeHtmlEntities(content[2]); + } + return undefined; +} + +export function extractOgTag( + html: string, + tag: string, +): string | undefined { + return extractMetaContent(html, "property", `og:${tag}`); +} + +/** Extract content from `` (both attribute orderings). */ +export function extractMetaName( + html: string, + name: string, +): string | undefined { + return extractMetaContent(html, "name", name); +} + +/** Extract the text content of the `` element. */ +export function extractPageTitle(html: string): string | undefined { + const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); + return match ? decodeHtmlEntities(match[1].trim()) : undefined; +} + +// ── Brand color helpers ─────────────────────────────────────────────────────── + +/** + * Find the page's `theme-color`. + * + * Sites routinely ship the tag twice, scoped by `media`, and often list the + * dark-scheme one first — so unlike `extractMetaName` this can't just take the + * first match. Prefer the unscoped tag, then the light-scheme one, then any. + */ +export function extractThemeColor(html: string): string | undefined { + const tagRe = /<meta\b[^>]*>/gi; + const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; + const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i; + let unscoped: string | undefined; + let light: string | undefined; + let any: string | undefined; + + let m: RegExpExecArray | null; + while ((m = tagRe.exec(html)) !== null) { + const tag = m[0]; + if (!/\bname=["']theme-color["']/i.test(tag)) continue; + const content = contentRe.exec(tag)?.[2]; + if (!content) continue; + const media = mediaRe.exec(tag)?.[2]; + if (!media) unscoped ??= content; + else if (/light/i.test(media)) light ??= content; + any ??= content; + } + return unscoped ?? light ?? any; +} + +/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ +export function extractMaskIconColor(html: string): string | undefined { + const linkRe = /<link[^>]+>/gi; + let m: RegExpExecArray | null; + while ((m = linkRe.exec(html)) !== null) { + const tag = m[0]; + if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue; + const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2]; + if (color) return color; + } + return undefined; +} + +// The handful of named colors that actually show up in `theme-color`. The full +// 148-name table isn't worth carrying for the long tail. +const NAMED_COLORS: Record<string, string> = { + black: "#000000", + white: "#ffffff", + red: "#ff0000", + green: "#008000", + blue: "#0000ff", + yellow: "#ffff00", + orange: "#ffa500", + purple: "#800080", + gray: "#808080", + grey: "#808080", + silver: "#c0c0c0", + maroon: "#800000", + navy: "#000080", + teal: "#008080", + olive: "#808000", + lime: "#00ff00", + aqua: "#00ffff", + cyan: "#00ffff", + fuchsia: "#ff00ff", + magenta: "#ff00ff", +}; + +function clampByte(n: number): number { + return Math.max(0, Math.min(255, Math.round(n))); +} + +/** + * Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when + * it isn't one of the forms sites actually use. Alpha is dropped — the color is + * only ever used as a tint over the app's own surface. + */ +export function normalizeCssColor(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const value = raw.trim().toLowerCase(); + + const named = NAMED_COLORS[value]; + if (named) return named; + + const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1]; + if (hex) { + if (hex.length === 3 || hex.length === 4) { + const [r, g, b] = [...hex.slice(0, 3)]; + return `#${r}${r}${g}${g}${b}${b}`; + } + if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`; + return undefined; + } + + const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1]; + if (rgb) { + const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3); + if (parts.length !== 3) return undefined; + const bytes = parts.map((p) => { + const n = parseFloat(p); + if (!Number.isFinite(n)) return NaN; + return clampByte(p.endsWith("%") ? (n / 100) * 255 : n); + }); + if (bytes.some(Number.isNaN)) return undefined; + return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`; + } + + return undefined; +} + +// ── JSON-LD helpers (file-private) ──────────────────────────────────────────── + +type JsonLdResult = { + title?: string; + description?: string; + thumbnailUrl?: string; +}; + +function ldString(v: unknown): string | undefined { + if (typeof v === "string" && v.trim()) return v.trim(); + if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) { + return v[0].trim(); + } + return undefined; +} + +function ldImage(v: unknown): string | undefined { + if ( + typeof v === "string" && + (v.startsWith("http://") || v.startsWith("https://")) + ) return v; + if (Array.isArray(v)) return ldImage(v[0]); + if (v && typeof v === "object") { + const o = v as Record<string, unknown>; + return ldImage(o.url ?? o.contentUrl); + } + return undefined; +} + +function ldExtractNode(data: unknown): JsonLdResult { + if (Array.isArray(data)) { + for (const item of data) { + const r = ldExtractNode(item); + if (r.title || r.thumbnailUrl) return r; + } + return {}; + } + if (!data || typeof data !== "object") return {}; + const o = data as Record<string, unknown>; + if (o["@graph"]) return ldExtractNode(o["@graph"]); + return { + title: ldString(o.name ?? o.headline), + description: ldString(o.description), + thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail), + }; +} + +/** + * Parse every `<script type="application/ld+json">` block and return the first + * node that yields a title or image. Handles `@graph`, arrays, and the common + * `image` shapes (string, string[], ImageObject). + */ +export function extractJsonLd(html: string): JsonLdResult { + const pattern = + /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(html)) !== null) { + try { + const result = ldExtractNode(JSON.parse(match[1])); + if (result.title || result.thumbnailUrl) return result; + } catch { /* invalid JSON — skip */ } + } + return {}; +} + +/** + * Return the `src` of the first `<img>` whose declared width or height is at + * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. + */ +export function extractLargeImage( + html: string, + baseUrl: string, + minSize = 200, +): string | undefined { + const imgPattern = /<img[^>]+>/gi; + let match: RegExpExecArray | null; + while ((match = imgPattern.exec(html)) !== null) { + const tag = match[0]; + const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; + if (!src || src.startsWith("data:")) continue; + const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); + const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); + if (w >= minSize && h >= minSize) { + try { + return new URL(src, baseUrl).toString(); + } catch { + continue; + } + } + } + return undefined; +} + +/** + * Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank + * them by declared size (largest wins), and return the best resolved URL. + * Falls back to the first match when no `sizes` attribute is present. + * + * SVG icons are ranked above everything: they carry no `sizes` attribute, so + * they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to + * whatever size the placeholder renders them at. + */ +export function extractBestIcon( + html: string, + baseUrl: string, +): string | undefined { + const linkRe = /<link[^>]+>/gi; + const relRe = /\brel=["']([^"']+)["']/i; + const hrefRe = /\bhref=["']([^"']+)["']/i; + const sizesRe = /\bsizes=["']([^"']+)["']/i; + + const candidates: { href: string; area: number }[] = []; + + let m: RegExpExecArray | null; + while ((m = linkRe.exec(html)) !== null) { + const tag = m[0]; + const rel = relRe.exec(tag)?.[1] ?? ""; + if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue; + const href = hrefRe.exec(tag)?.[1]; + if (!href) continue; + const sizesStr = sizesRe.exec(tag)?.[1] ?? ""; + const sm = sizesStr.match(/(\d+)x(\d+)/i); + const isSvg = /\.svg(\?|$)/i.test(href) || + /\btype=["']image\/svg\+xml["']/i.test(tag); + const area = isSvg + ? Number.MAX_SAFE_INTEGER + : sm + ? parseInt(sm[1]) * parseInt(sm[2]) + : 0; + try { + candidates.push({ href: new URL(href, baseUrl).toString(), area }); + } catch { + continue; + } + } + + if (candidates.length === 0) return undefined; + candidates.sort((a, b) => b.area - a.area); + return candidates[0].href; +} + +/** + * Return the `src` of the first `<img>` that looks like content rather than UI + * chrome. Skips SVGs, data URIs, and images whose filename matches common + * icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.). + * Resolves relative and protocol-relative URLs. + */ +const UI_IMAGE_KEYWORDS = new Set([ + "logo", + "icon", + "sprite", + "favicon", + "avatar", + "banner", + "header", + "nav", + "menu", + "cart", + "search", + "tracking", + "pixel", + "bg", + "background", +]); + +function isUiImage(src: string): boolean { + if (/\.svg(\?|$)/i.test(src)) return true; + const filename = src.split("?")[0].split("/").pop() ?? ""; + const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension + // Split on common filename separators (-, _, .) and check each token + return baseName.toLowerCase().split(/[-_.]/).some((t) => + UI_IMAGE_KEYWORDS.has(t) + ); +} + +/** + * Return the `src` of the first `<img>` that looks like content rather than UI + * chrome. Skips SVGs, data URIs, and images whose filename tokens match common + * icon/logo/nav patterns (logo, icon, sprite, etc.). + * Resolves relative and protocol-relative URLs. + */ +export function extractFirstContentImage( + html: string, + baseUrl: string, +): string | undefined { + const imgPattern = /<img[^>]+>/gi; + let match: RegExpExecArray | null; + while ((match = imgPattern.exec(html)) !== null) { + const tag = match[0]; + const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; + if (!src || src.startsWith("data:")) continue; + if (isUiImage(src)) continue; + try { + return new URL(src, baseUrl).toString(); + } catch { + continue; + } + } + return undefined; +} + +/** + * Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`, + * resolved to an absolute URL using `baseUrl`. + */ +export function extractLinkHref( + html: string, + relFragment: string, + baseUrl: string, +): string | undefined { + const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`, + "i", + ), + new RegExp( + `<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) { + try { + return new URL(match[1], baseUrl).toString(); + } catch { + return undefined; + } + } + } + return undefined; +} diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts new file mode 100644 index 00000000..a223ccae --- /dev/null +++ b/api/remote/image-proxy.ts @@ -0,0 +1,106 @@ +import { fetchWithTimeout } from "./fetch.ts"; + +export interface ProxiedImage { + bytes: Uint8Array; + contentType: string; +} + +const PROXYABLE_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/avif", + "image/bmp", + "image/x-icon", + "image/vnd.microsoft.icon", +]); + +// Proxied images, by URL: every viewer of a feed asks for the same +// thumbnails, which were fetched from their hosts again for each one. Bounded +// by total size; the least recently used go first. +const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; +const imageCache = new Map< + string, + { bytes: Uint8Array; contentType: string; at: number } +>(); +let imageCacheBytes = 0; + +function readImageCache( + url: string, +): { bytes: Uint8Array; contentType: string } | undefined { + const hit = imageCache.get(url); + if (!hit) return undefined; + imageCache.delete(url); + if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) { + imageCacheBytes -= hit.bytes.byteLength; + return undefined; + } + imageCache.set(url, hit); // most recently used + return hit; +} + +function writeImageCache( + url: string, + bytes: Uint8Array, + contentType: string, +): void { + const old = imageCache.get(url); + if (old) { + imageCache.delete(url); + imageCacheBytes -= old.bytes.byteLength; + } + while ( + imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES && + imageCache.size > 0 + ) { + const [oldestUrl, oldest] = imageCache.entries().next().value!; + imageCache.delete(oldestUrl); + imageCacheBytes -= oldest.bytes.byteLength; + } + imageCache.set(url, { bytes, contentType, at: Date.now() }); + imageCacheBytes += bytes.byteLength; +} + +/** Forget every proxied image (tests). */ +export function clearImageCache(): void { + imageCache.clear(); + imageCacheBytes = 0; +} + +/** + * The image at `url`, from the cache or else from its host; null when the host + * answers with something that isn't a raster image of 5 MB at most. Throws when + * the host can't be reached. + */ +export async function fetchProxiedImage( + url: string, +): Promise<ProxiedImage | null> { + const cached = readImageCache(url); + if (cached) return cached; + + const res = await fetchWithTimeout(url, 8000); + // Raster formats only. The bytes go out under this site's origin, so an + // SVG (which can carry script) or anything else a browser might execute + // would run with the app's origin if opened directly. + const contentType = (res.headers.get("content-type") ?? "") + .split(";")[0].trim().toLowerCase(); + if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { + await res.body?.cancel(); + return null; + } + + const MAX_SIZE = 5 * 1024 * 1024; // 5 MB + const contentLength = Number(res.headers.get("content-length") ?? "0"); + if (contentLength > MAX_SIZE) { + await res.body?.cancel(); + return null; + } + + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.length > MAX_SIZE) return null; + + writeImageCache(url, bytes, contentType); + return { bytes, contentType }; +} diff --git a/api/services/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts similarity index 79% rename from api/services/providers/bandcamp.ts rename to api/remote/providers/bandcamp.ts index c1c4d096..13ba4a74 100644 --- a/api/services/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -1,10 +1,7 @@ -import type { RichContent } from "../../model/interfaces.ts"; -import type { RichContentProvider } from "../rich-content-service.ts"; -import { - extractOgTag, - fetchWithTimeout, - readTextCapped, -} from "../rich-content-service.ts"; +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/; diff --git a/api/services/providers/generic.ts b/api/remote/providers/generic.ts similarity index 94% rename from api/services/providers/generic.ts rename to api/remote/providers/generic.ts index 92076b6b..61de230a 100644 --- a/api/services/providers/generic.ts +++ b/api/remote/providers/generic.ts @@ -1,5 +1,5 @@ -import type { RichContent } from "../../model/interfaces.ts"; -import type { RichContentProvider } from "../rich-content-service.ts"; +import type { RichContent } from "../../../shared/dump.ts"; +import type { RichContentProvider } from "./provider.ts"; import { extractBestIcon, extractFirstContentImage, @@ -10,10 +10,9 @@ import { extractOgTag, extractPageTitle, extractThemeColor, - fetchWithTimeout, normalizeCssColor, - readTextCapped, -} from "../rich-content-service.ts"; +} from "../html.ts"; +import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; export const genericProvider: RichContentProvider = { name: "generic", diff --git a/api/remote/providers/provider.ts b/api/remote/providers/provider.ts new file mode 100644 index 00000000..4f6e059c --- /dev/null +++ b/api/remote/providers/provider.ts @@ -0,0 +1,8 @@ +import type { RichContent } from "../../../shared/dump.ts"; + +/** Metadata for the links one kind of site serves. */ +export interface RichContentProvider { + name: string; + matches(url: string): boolean; + fetch(url: string): Promise<RichContent>; +} diff --git a/api/services/providers/self.ts b/api/remote/providers/self.ts similarity index 88% rename from api/services/providers/self.ts rename to api/remote/providers/self.ts index fbb11b93..304a17ae 100644 --- a/api/services/providers/self.ts +++ b/api/remote/providers/self.ts @@ -1,8 +1,8 @@ -import type { RichContent } from "../../model/interfaces.ts"; -import type { RichContentProvider } from "../rich-content-service.ts"; -import { getDump } from "../dump-service.ts"; -import { getUserByUsername } from "../user-service.ts"; -import { getPlaylistById } from "../playlist-service.ts"; +import type { RichContent } from "../../../shared/dump.ts"; +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 { PUBLIC_URL, THEME_COLOR } from "../../config.ts"; import { isSelfUrl } from "../../lib/self-url.ts"; diff --git a/api/services/providers/soundcloud.ts b/api/remote/providers/soundcloud.ts similarity index 80% rename from api/services/providers/soundcloud.ts rename to api/remote/providers/soundcloud.ts index 8212cd60..487f3d34 100644 --- a/api/services/providers/soundcloud.ts +++ b/api/remote/providers/soundcloud.ts @@ -1,10 +1,7 @@ -import type { RichContent } from "../../model/interfaces.ts"; -import type { RichContentProvider } from "../rich-content-service.ts"; -import { - extractOgTag, - fetchWithTimeout, - readTextCapped, -} from "../rich-content-service.ts"; +import type { RichContent } from "../../../shared/dump.ts"; +import type { RichContentProvider } from "./provider.ts"; +import { extractOgTag } from "../html.ts"; +import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; export const soundcloudProvider: RichContentProvider = { name: "soundcloud", diff --git a/api/services/providers/youtube.ts b/api/remote/providers/youtube.ts similarity index 95% rename from api/services/providers/youtube.ts rename to api/remote/providers/youtube.ts index a02bb128..44139d67 100644 --- a/api/services/providers/youtube.ts +++ b/api/remote/providers/youtube.ts @@ -1,10 +1,7 @@ -import type { RichContent } from "../../model/interfaces.ts"; -import type { RichContentProvider } from "../rich-content-service.ts"; -import { - extractOgTag, - fetchWithTimeout, - readTextCapped, -} from "../rich-content-service.ts"; +import type { RichContent } from "../../../shared/dump.ts"; +import type { RichContentProvider } from "./provider.ts"; +import { extractOgTag } from "../html.ts"; +import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; function extractVideoId(url: string): string | null { try { diff --git a/api/lib/public-address.ts b/api/remote/public-address.ts similarity index 100% rename from api/lib/public-address.ts rename to api/remote/public-address.ts diff --git a/api/remote/twemoji.ts b/api/remote/twemoji.ts new file mode 100644 index 00000000..0c704d71 --- /dev/null +++ b/api/remote/twemoji.ts @@ -0,0 +1,36 @@ +// Pinned Twemoji release. +const TWEMOJI_VERSION = "16.0.1"; + +// Cache the fetched glyph per emoji so repeated runs don't re-hit the network. +const glyphCache = new Map<string, string>(); + +/** + * Twemoji's plain vector-path SVG for `emoji`, whose file is named `cp` (see + * emojiToCodepoint in lib/site-icons.ts). + */ +export async function fetchTwemojiSvg( + emoji: string, + cp: string, +): Promise<string> { + const cached = glyphCache.get(cp); + if (cached) return cached; + const url = + `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`; + let res: Response; + try { + res = await fetch(url); + } catch (cause) { + throw new Error( + `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`, + { cause }, + ); + } + if (!res.ok) { + throw new Error( + `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`, + ); + } + const svg = await res.text(); + glyphCache.set(cp, svg); + return svg; +} diff --git a/api/services/youtube-playlist-service.ts b/api/remote/youtube-playlist.ts similarity index 94% rename from api/services/youtube-playlist-service.ts rename to api/remote/youtube-playlist.ts index af3f3688..80f0a5cf 100644 --- a/api/services/youtube-playlist-service.ts +++ b/api/remote/youtube-playlist.ts @@ -1,5 +1,10 @@ -import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { readTextCapped } from "./rich-content-service.ts"; +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { readTextCapped } from "./fetch.ts"; +import type { + YouTubePlaylist, + YouTubePlaylistTrack, +} from "../../shared/player.ts"; /** * Resolves a YouTube playlist to its tracklist, at play time. @@ -17,19 +22,6 @@ import { readTextCapped } from "./rich-content-service.ts"; * further IDs the player reports, untitled. */ -export interface YouTubePlaylistTrack { - videoId: string; - title: string; - /** Seconds. */ - duration?: number; -} - -export interface YouTubePlaylist { - listId: string; - title?: string; - tracks: YouTubePlaylistTrack[]; -} - /** Titles and lengths rarely change; a stale entry only mislabels a row. */ const TTL_MS = 6 * 60 * 60 * 1000; const MAX_ENTRIES = 200; diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index 1040ec26..18b660c2 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -4,12 +4,9 @@ import { createAttachment, getAttachment, } from "../services/attachment-service.ts"; -import { - APIErrorCode, - APIException, - type APIResponse, - type AttachmentResponse, -} from "../model/interfaces.ts"; +import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { AttachmentResponse } from "../../shared/dump.ts"; import { ATTACHMENTS_DIR, serveUploadedFile, diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 0331b06e..0f6ad8af 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -5,14 +5,10 @@ import { broadcastUserUpdated, updateClientAvatar, } from "../services/ws-service.ts"; -import { - APIErrorCode, - APIException, - type APIResponse, - type SelfUser, - toPublicUser, - toSelfUser, -} from "../model/interfaces.ts"; +import { APIErrorCode, 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, serveUploadedFile, diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts index 47a08454..2c01dde5 100644 --- a/api/routes/bandcamp.ts +++ b/api/routes/bandcamp.ts @@ -1,7 +1,7 @@ import { Router } from "@oak/oak"; -import type { APIResponse } from "../model/interfaces.ts"; -import { resolveBandcamp } from "../services/bandcamp-stream-service.ts"; -import type { Tralbum } from "../services/bandcamp-tralbum.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; +import type { Tralbum } from "../../shared/player.ts"; const bandcampRouter = new Router(); diff --git a/api/routes/categories.ts b/api/routes/categories.ts index a4453825..5b5d3c7e 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -2,14 +2,16 @@ import { Router } from "@oak/oak"; import { APIErrorCode, - APIException, type APIResponse, - type Category, - type Dump, + 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"; +import { isCreateCategoryRequest, isUpdateCategoryRequest, - type PaginatedData, -} from "../model/interfaces.ts"; +} from "../validators/category.ts"; import { authMiddleware, requirePermission } from "../middleware/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; diff --git a/api/routes/chat.ts b/api/routes/chat.ts index 8dc5311f..9c591757 100644 --- a/api/routes/chat.ts +++ b/api/routes/chat.ts @@ -1,5 +1,6 @@ import { Router } from "@oak/oak"; -import type { APIResponse, ChatMessage } from "../model/interfaces.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import type { ChatMessage } from "../../shared/chat.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { getChatMessagesAfter, diff --git a/api/routes/comments.ts b/api/routes/comments.ts index de2b4a1c..76af1f86 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -1,15 +1,17 @@ import { Router } from "@oak/oak"; import { APIErrorCode, - APIException, type APIResponse, - type Comment, + type PaginatedData, +} from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { Comment } from "../../shared/comment.ts"; +import { isCreateCommentRequest, isUpdateCommentRequest, - type PaginatedData, - type PublicUser, - toPublicUser, -} from "../model/interfaces.ts"; +} from "../validators/comment.ts"; +import type { PublicUser } from "../../shared/user.ts"; +import { toPublicUser } from "../model/user.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index e63054d3..49766775 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -2,17 +2,18 @@ import { Router } from "@oak/oak"; import { APIErrorCode, - APIException, type APIResponse, - type Dump, - type DumpUrlMatch, + type PaginatedData, +} from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { Dump, DumpUrlMatch } from "../../shared/dump.ts"; +import { isCreateUrlDumpRequest, isUpdateDumpRequest, - type PaginatedData, - type Playlist, - type PublicUser, - toPublicUser, -} from "../model/interfaces.ts"; +} from "../validators/dump.ts"; +import type { Playlist } from "../../shared/playlist.ts"; +import type { PublicUser } from "../../shared/user.ts"; +import { toPublicUser } from "../model/user.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; diff --git a/api/routes/files.ts b/api/routes/files.ts index 87316869..07b6b928 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,5 +1,6 @@ import { Router } from "@oak/oak"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; import { authenticateMedia } from "../lib/auth.ts"; import { serveFile } from "../lib/serve-file.ts"; diff --git a/api/routes/follows.ts b/api/routes/follows.ts index b51ea040..c5f28f22 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -1,11 +1,8 @@ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; -import { - type APIResponse, - type Dump, - type FollowStatus, - type PaginatedData, -} from "../model/interfaces.ts"; +import type { APIResponse, PaginatedData } from "../../shared/api.ts"; +import type { Dump } from "../../shared/dump.ts"; +import type { FollowStatus } from "../../shared/follow.ts"; import { type Page, parsePagination } from "../lib/pagination.ts"; import { followPlaylist, diff --git a/api/routes/invites.ts b/api/routes/invites.ts index fb1cc32b..ed1bc2e0 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -1,10 +1,7 @@ import { Router } from "@oak/oak"; -import { - APIErrorCode, - APIException, - type APIResponse, - type InviteResponse, -} from "../model/interfaces.ts"; +import { APIErrorCode, 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"; import { createInvite, validateInvite } from "../services/invite-service.ts"; diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 53e3273e..2d6db119 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -1,11 +1,11 @@ import { Router } from "@oak/oak"; import { APIErrorCode, - APIException, type APIResponse, - type Notification, type PaginatedData, -} from "../model/interfaces.ts"; +} from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { Notification } from "../../shared/notification.ts"; import { parsePagination } from "../lib/pagination.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index 8ea1705b..445d1672 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -1,16 +1,17 @@ import { Router } from "@oak/oak"; import { parseOptionalAuth } from "../lib/auth.ts"; +import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; import { - APIErrorCode, - APIException, - type APIResponse, isCreatePlaylistRequest, isReorderPlaylistRequest, isUpdatePlaylistRequest, - type Playlist, - type PlaylistMembership, - type PlaylistWithDumps, -} from "../model/interfaces.ts"; +} from "../validators/playlist.ts"; +import type { + Playlist, + PlaylistMembership, + PlaylistWithDumps, +} from "../../shared/playlist.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 0f258227..197bb07b 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -1,30 +1,17 @@ import { Router } from "@oak/oak"; -import { - fetchWithTimeout, - isValidHttpUrl, - tryFetchRichContent, -} from "../services/rich-content-service.ts"; +import { isValidHttpUrl } from "../remote/fetch.ts"; +import { fetchProxiedImage } from "../remote/image-proxy.ts"; +import { tryFetchRichContent } from "../services/rich-content-service.ts"; import { APIErrorCode, type APIFailure, type APIResponse, - type LinkPreview, -} from "../model/interfaces.ts"; +} from "../../shared/api.ts"; +import type { LinkPreview } from "../../shared/dump.ts"; import { authMiddleware } from "../middleware/auth.ts"; const previewRouter = new Router(); -const PROXYABLE_IMAGE_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/avif", - "image/bmp", - "image/x-icon", - "image/vnd.microsoft.icon", -]); - // Signed-in only: it's the create form's preview, and an open one lets anyone // use the server to fetch pages for them. previewRouter.get("/api/preview", authMiddleware, async (ctx) => { @@ -50,59 +37,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// Proxied images, by URL: every viewer of a feed asks for the same -// thumbnails, which were fetched from their hosts again for each one. Bounded -// by total size; the least recently used go first. -const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; -const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; -const imageCache = new Map< - string, - { bytes: Uint8Array; contentType: string; at: number } ->(); -let imageCacheBytes = 0; - -function readImageCache( - url: string, -): { bytes: Uint8Array; contentType: string } | undefined { - const hit = imageCache.get(url); - if (!hit) return undefined; - imageCache.delete(url); - if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) { - imageCacheBytes -= hit.bytes.byteLength; - return undefined; - } - imageCache.set(url, hit); // most recently used - return hit; -} - -function writeImageCache( - url: string, - bytes: Uint8Array, - contentType: string, -): void { - const old = imageCache.get(url); - if (old) { - imageCache.delete(url); - imageCacheBytes -= old.bytes.byteLength; - } - while ( - imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES && - imageCache.size > 0 - ) { - const [oldestUrl, oldest] = imageCache.entries().next().value!; - imageCache.delete(oldestUrl); - imageCacheBytes -= oldest.bytes.byteLength; - } - imageCache.set(url, { bytes, contentType, at: Date.now() }); - imageCacheBytes += bytes.byteLength; -} - -/** Forget every proxied image (tests). */ -export function clearImageCache(): void { - imageCache.clear(); - imageCacheBytes = 0; -} - function sendImage( ctx: { response: { headers: Headers; body?: unknown } }, bytes: Uint8Array, @@ -129,41 +63,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => { return; } - const cached = readImageCache(url); - if (cached) { - sendImage(ctx, cached.bytes, cached.contentType); - return; - } - try { - const res = await fetchWithTimeout(url, 8000); - // Raster formats only. The bytes go out under this site's origin, so an - // SVG (which can carry script) or anything else a browser might execute - // would run with the app's origin if opened directly. - const contentType = (res.headers.get("content-type") ?? "") - .split(";")[0].trim().toLowerCase(); - if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { - await res.body?.cancel(); + const image = await fetchProxiedImage(url); + if (!image) { ctx.response.status = 400; return; } - - const MAX_SIZE = 5 * 1024 * 1024; // 5 MB - const contentLength = Number(res.headers.get("content-length") ?? "0"); - if (contentLength > MAX_SIZE) { - await res.body?.cancel(); - ctx.response.status = 400; - return; - } - - const bytes = new Uint8Array(await res.arrayBuffer()); - if (bytes.length > MAX_SIZE) { - ctx.response.status = 400; - return; - } - - writeImageCache(url, bytes, contentType); - sendImage(ctx, bytes, contentType); + sendImage(ctx, image.bytes, image.contentType); } catch { ctx.response.status = 502; } diff --git a/api/routes/search.ts b/api/routes/search.ts index d85199a9..40199d7d 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -4,7 +4,8 @@ import { parsePagination } from "../lib/pagination.ts"; import { searchDumps } from "../services/dump-service.ts"; import { searchUsersGlobal } from "../services/user-service.ts"; import { searchPlaylists } from "../services/playlist-service.ts"; -import type { APIResponse, SearchResults } from "../model/interfaces.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import type { SearchResults } from "../../shared/search.ts"; const router = new Router({ prefix: "/api/search" }); diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index a2fd3ab4..78d3693b 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -1,5 +1,6 @@ import { Router } from "@oak/oak"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; import { dumpFileVersion, getDump, diff --git a/api/routes/users.ts b/api/routes/users.ts index a023d529..3bc13638 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -2,28 +2,30 @@ import { Router } from "@oak/oak"; import { APIErrorCode, - APIException, type APIResponse, + type PaginatedData, +} from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { type AuthResponse, type AuthTokens, - type Dump, - fieldOf, type Invitee, - isLoginUserRequest, - isRegisterUserRequest, isRole, - isUpdateUserRequest, type MediaTokenResponse, - type PaginatedData, - type Playlist, type PublicUser, type SelfUser, - toPublicUser, - toSelfUser, - type User, type UserSuggestion, +} from "../../shared/user.ts"; +import type { Dump } from "../../shared/dump.ts"; +import { + fieldOf, + isLoginUserRequest, + isRegisterUserRequest, + isUpdateUserRequest, validateRegisterUserRequest, -} from "../model/interfaces.ts"; +} from "../validators/user.ts"; +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 { type RateLimit, takeToken } from "../lib/rate-limit.ts"; @@ -48,12 +50,8 @@ import { resetPassword, } from "../services/password-reset-service.ts"; import { isEmailEnabled, sendEmail } from "../services/email-service.ts"; -import { - FROM_EMAIL, - OG_SITE_NAME, - VALIDATION, - WELCOME_EMAIL_BODY, -} from "../config.ts"; +import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts"; +import { VALIDATION } from "../../shared/validation.ts"; import { marked } from "marked"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 68a4c2d2..f2e0f499 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -27,8 +27,11 @@ import { markChatRead, updateChatMessage, } from "../services/chat-service.ts"; -import { notifyMentions } from "../services/notification-service.ts"; -import { hasPermission } from "../lib/permissions.ts"; +import { + getUnreadCount, + notifyMentions, +} from "../services/notification-service.ts"; +import { hasPermission } from "../../shared/permissions.ts"; import { castVote, getUserVotes, @@ -39,14 +42,13 @@ import { getUserCommentLikes, removeCommentLike, } from "../services/comment-like-service.ts"; -import { getUnreadCount } from "../services/notification-service.ts"; import { getUserById } from "../services/user-service.ts"; -import { - APIException, - type ClientToServerMessage, - type ServerToClientMessage, - type User, -} from "../model/interfaces.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + ClientToServerMessage, + ServerToClientMessage, +} from "../../shared/ws.ts"; +import type { User } from "../model/user.ts"; const router = new Router(); diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts index cecd82b7..ed796257 100644 --- a/api/routes/youtube.ts +++ b/api/routes/youtube.ts @@ -1,9 +1,7 @@ import { Router } from "@oak/oak"; -import type { APIResponse } from "../model/interfaces.ts"; -import { - resolveYouTubePlaylist, - type YouTubePlaylist, -} from "../services/youtube-playlist-service.ts"; +import type { APIResponse } from "../../shared/api.ts"; +import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; +import type { YouTubePlaylist } from "../../shared/player.ts"; const youtubeRouter = new Router(); diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index b4ee1cf6..ad42fc4e 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -1,5 +1,6 @@ -import { db, transaction } from "../model/db.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { db, transaction } from "../db/connection.ts"; +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; export function createAttachment(id: string, mime: string): void { db.prepare( diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index 3a438e30..f4653bb0 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -1,11 +1,10 @@ -import { type Dump } from "../model/interfaces.ts"; +import type { Dump } from "../../shared/dump.ts"; +import { db, transaction } from "../db/connection.ts"; import { - db, DUMP_SELECT_COLUMNS as SELECT_COLS, dumpRowToApi, isDumpRow, - transaction, -} from "../model/db.ts"; +} from "../model/dump.ts"; import { UUID_RE } from "../lib/slugify.ts"; // Matches http(s) URLs in free text — stops at whitespace or markdown-link diff --git a/api/services/category-service.ts b/api/services/category-service.ts index c0c1b972..ee98fb98 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -1,23 +1,21 @@ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + Category, + CreateCategoryRequest, + UpdateCategoryRequest, +} from "../../shared/category.ts"; +import type { Dump } from "../../shared/dump.ts"; +import { categoryRowToApi, isCategoryRow } from "../model/category.ts"; +import { db, transaction } from "../db/connection.ts"; import { - APIErrorCode, - APIException, - type Category, - type CreateCategoryRequest, - type Dump, - type UpdateCategoryRequest, -} from "../model/interfaces.ts"; -import { - categoryRowToApi, - db, DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpRowToApi, - isCategoryRow, isDumpRow, - transaction, -} from "../model/db.ts"; +} from "../model/dump.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; -import { VALIDATION } from "../config.ts"; +import { VALIDATION } from "../../shared/validation.ts"; function validateSlug(slug: string): void { if (!isValidSlugFormat(slug)) { diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index d8adf166..ebee27fc 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -1,12 +1,11 @@ -import { - APIErrorCode, - APIException, - type ChatMessage, -} from "../model/interfaces.ts"; -import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts"; +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"; +import { db } from "../db/connection.ts"; import { linkAttachments } from "./attachment-service.ts"; +import { VALIDATION } from "../../shared/validation.ts"; -export const MAX_CHAT_LENGTH = 2000; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; @@ -51,11 +50,11 @@ export function createChatMessage( "Message cannot be empty", ); } - if (trimmed.length > MAX_CHAT_LENGTH) { + if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, - `Message exceeds ${MAX_CHAT_LENGTH} characters`, + `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } // Only store the reply target if it actually exists — drop dangling ids so a @@ -111,11 +110,11 @@ export function updateChatMessage( "Message cannot be empty", ); } - if (trimmed.length > MAX_CHAT_LENGTH) { + if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, - `Message exceeds ${MAX_CHAT_LENGTH} characters`, + `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index 5c630c71..f2b39ab1 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -1,5 +1,7 @@ -import { APIErrorCode, APIException, type User } from "../model/interfaces.ts"; -import { db, isUserRow, userRowToApi } from "../model/db.ts"; +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"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index a97c0c1f..15dac887 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -1,10 +1,9 @@ -import { - APIErrorCode, - APIException, - type Comment, -} from "../model/interfaces.ts"; +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"; -import { commentRowToApi, db, isCommentRow } from "../model/db.ts"; +import { commentRowToApi, isCommentRow } from "../model/comment.ts"; +import { db } from "../db/connection.ts"; import { notifyDumpOwnerNewComment, notifyMentions, diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index f8af384d..cddc0c10 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -1,26 +1,25 @@ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + CreateUrlDumpRequest, + Dump, + DumpUrlMatch, + UpdateDumpRequest, +} from "../../shared/dump.ts"; +import { db, transaction } from "../db/connection.ts"; import { - APIErrorCode, - APIException, - type CreateUrlDumpRequest, - type Dump, - type DumpUrlMatch, - type UpdateDumpRequest, -} from "../model/interfaces.ts"; -import { - db, DUMP_SELECT_COLUMNS as SELECT_COLS, DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpApiToRow, dumpRowToApi, isDumpRow, - transaction, -} from "../model/db.ts"; +} from "../model/dump.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { fetchRichContent, - isValidHttpUrl, tryFetchRichContent, } from "./rich-content-service.ts"; +import { isValidHttpUrl } from "../remote/fetch.ts"; import { broadcastDumpDeleted, broadcastDumpUpdated, @@ -35,11 +34,13 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { DUMP_ALLOWED_MIME_PREFIXES, DUMP_ALLOWED_MIME_TYPES, - DUMP_MAX_FILE_SIZE_BYTES, DUMPS_DIR, THUMBNAILS_DIR, - VALIDATION, } from "../config.ts"; +import { + DUMP_MAX_FILE_SIZE_BYTES, + VALIDATION, +} from "../../shared/validation.ts"; import { linkAttachments } from "./attachment-service.ts"; import { relinkBacklinks } from "./backlink-service.ts"; import { diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index b2089480..31c71796 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -1,26 +1,21 @@ -import { - APIErrorCode, - APIException, - type Dump, - type FollowStatus, - type Playlist, - type User, -} from "../model/interfaces.ts"; +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"; +import type { Playlist } from "../../shared/playlist.ts"; +import { isUserRow, type User, userRowToApi } from "../model/user.ts"; import { notifyPlaylistOwnerNewFollower, notifyUserNewFollower, } from "./notification-service.ts"; +import { db } from "../db/connection.ts"; import { - db, DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpRowToApi, isDumpRow, - isFollowRow, - isPlaylistRow, - isUserRow, - playlistRowToApi, - userRowToApi, -} from "../model/db.ts"; +} from "../model/dump.ts"; +import { isFollowRow } from "../model/follow.ts"; +import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds } from "./category-service.ts"; diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts index e246ad7d..6bdce2cd 100644 --- a/api/services/invite-service.ts +++ b/api/services/invite-service.ts @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { db, isInviteRow } from "../model/db.ts"; +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"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; /** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */ diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 3237a64d..86530fb7 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -3,20 +3,17 @@ import type { NotificationData, NotificationType, UserDumpPostedData, -} from "../model/interfaces.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +} 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 { - db, isNotificationRow, notificationRowToApi, - transaction, -} from "../model/db.ts"; +} from "../model/notification.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; - -// Regex: matches @username not already inside a markdown link ([...] or (...) -// and not mid-word, so an email address doesn't mention its domain. -const MENTION_RE = /(?<![[(\w])@(\w+)/g; +import { MENTION_RE } from "../../shared/mention.ts"; // ── Core CRUD ───────────────────────────────────────────────────────────────── @@ -28,7 +25,8 @@ function createNotification( sourceKey: string | null = null, ): void { const id = crypto.randomUUID(); - const createdAt = new Date().toISOString(); + const now = new Date(); + const createdAt = now.toISOString(); const dataJson = JSON.stringify(data); let changes: number; @@ -51,7 +49,14 @@ function createNotification( if (changes > 0) { sendToUser(userId, { type: "notification_created", - notification: { id, userId, type, data, read: false, createdAt }, + notification: { + id, + userId, + type, + data, + read: false, + createdAt: now, + }, }); } } @@ -166,7 +171,8 @@ export function notifyUserFollowersNewDump( dumpTitle, }; const dataJson = JSON.stringify(data); - const createdAt = new Date().toISOString(); + const now = new Date(); + const createdAt = now.toISOString(); const sourceKey = `dump:${dumpId}`; // Insert per follower so we can broadcast only the rows that were actually @@ -201,7 +207,7 @@ export function notifyUserFollowersNewDump( type: "user_dump_posted", data, read: false, - createdAt, + createdAt: now, }, }); } diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index b8eb2d7f..b1caf6ba 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -1,17 +1,15 @@ -import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { db, isPasswordResetTokenRow } from "../model/db.ts"; +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"; import { createPasswordResetToken, verifyPasswordResetToken, } from "../lib/jwt.ts"; import { getUserByEmail, updateUser } from "./user-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; -import { - FROM_EMAIL, - FRONTEND_URL, - OG_SITE_NAME, - VALIDATION, -} from "../config.ts"; +import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME } from "../config.ts"; +import { VALIDATION } from "../../shared/validation.ts"; import { marked } from "marked"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 691c2ad3..89075dcc 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -1,22 +1,20 @@ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + CreatePlaylistRequest, + Playlist, + PlaylistMembership, + PlaylistWithDumps, + UpdatePlaylistRequest, +} from "../../shared/playlist.ts"; +import type { Dump } from "../../shared/dump.ts"; +import { db, transaction } from "../db/connection.ts"; import { - APIErrorCode, - APIException, - type CreatePlaylistRequest, - type Dump, - type Playlist, - type PlaylistMembership, - type PlaylistWithDumps, - type UpdatePlaylistRequest, -} from "../model/interfaces.ts"; -import { - db, DUMP_COLUMNS_ALIASED, dumpRowToApi, isDumpRow, - isPlaylistRow, - playlistRowToApi, - transaction, -} from "../model/db.ts"; +} from "../model/dump.ts"; +import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { broadcastPlaylistCreated, diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index dc5bfa8d..eda744fe 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -1,20 +1,10 @@ -import type { RichContent } from "../model/interfaces.ts"; -import { youtubeProvider } from "./providers/youtube.ts"; -import { bandcampProvider } from "./providers/bandcamp.ts"; -import { soundcloudProvider } from "./providers/soundcloud.ts"; -import { selfProvider } from "./providers/self.ts"; -import { genericProvider } from "./providers/generic.ts"; -import { - isPrivateHostname, - resolvePublicAddress, -} from "../lib/public-address.ts"; -import { isSelfUrl } from "../lib/self-url.ts"; - -export interface RichContentProvider { - name: string; - matches(url: string): boolean; - fetch(url: string): Promise<RichContent>; -} +import type { RichContent } from "../../shared/dump.ts"; +import type { RichContentProvider } from "../remote/providers/provider.ts"; +import { youtubeProvider } from "../remote/providers/youtube.ts"; +import { bandcampProvider } from "../remote/providers/bandcamp.ts"; +import { soundcloudProvider } from "../remote/providers/soundcloud.ts"; +import { selfProvider } from "../remote/providers/self.ts"; +import { genericProvider } from "../remote/providers/generic.ts"; /** * Register providers in priority order. The first match wins. @@ -29,651 +19,6 @@ const providers: RichContentProvider[] = [ genericProvider, ]; -// Shared utilities exported for use by providers - -const FETCH_HEADERS = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Accept": - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", -}; - -const MAX_REDIRECTS = 5; - -/** - * Fetch `url` via a `curl --insecure` subprocess and return a minimal Response. - * Used as a last resort when Deno's rustls rejects the server's TLS certificate - * due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). - * Returns null if curl is unavailable or exits non-zero. - * - * `address` pins the connection to the IP already checked by - * resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes - * back with its Location so fetchWithTimeout can check the next hop too. - */ -async function fetchViaCurl( - url: URL, - address: string, - timeoutMs: number, -): Promise<Response | null> { - const tmpPath = await Deno.makeTempFile(); - const port = url.port || (url.protocol === "https:" ? "443" : "80"); - const pinned = address.includes(":") ? `[${address}]` : address; - try { - const { code, stdout } = await new Deno.Command("curl", { - args: [ - "--silent", - "--insecure", - "--proto", - "=http,https", - "--resolve", - `${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`, - "--max-time", - String(Math.ceil(timeoutMs / 1000)), - "--max-filesize", - String(2 * MAX_PAGE_BYTES), - "--user-agent", - FETCH_HEADERS["User-Agent"], - "--header", - `Accept: ${FETCH_HEADERS["Accept"]}`, - "--header", - `Accept-Language: ${FETCH_HEADERS["Accept-Language"]}`, - "--output", - tmpPath, - "--write-out", - "%{http_code}\n%{redirect_url}\n%{content_type}", - url.href, - ], - stdout: "piped", - stderr: "null", - }).output(); - - if (code !== 0) return null; - - const [status, location, contentType = ""] = new TextDecoder() - .decode(stdout).split("\n").map((l) => l.trim()); - const statusCode = Number(status); - if (!(statusCode >= 200 && statusCode <= 599)) return null; - const headers = new Headers(); - if (location) headers.set("location", location); - if (contentType) headers.set("content-type", contentType); - - const bytes = await Deno.readFile(tmpPath); - if (!contentType.startsWith("text/")) { - return new Response(bytes, { status: statusCode, headers }); - } - // Decode using the charset declared in the Content-Type header so that - // pages served in ISO-8859-1, windows-1252, etc. are read correctly. - const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; - let bodyText: string; - try { - bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes); - } catch { - bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes); - } - headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8")); - return new Response(bodyText, { status: statusCode, headers }); - } catch { - return null; - } finally { - await Deno.remove(tmpPath).catch(() => {}); - } -} - -/** - * Most bytes of a fetched page that are read. Metadata lives in the <head>, - * and a "page" past this is a download, not something to scrape. - */ -export const MAX_PAGE_BYTES = 4 * 1024 * 1024; - -/** - * Hands `body` on, and clears the request's deadline once it has been read to - * the end or cancelled. Until then the deadline still applies: a server that - * answers at once and then drips its body can't hold the request open. - */ -function withDeadline( - body: ReadableStream<Uint8Array>, - timer: ReturnType<typeof setTimeout>, -): ReadableStream<Uint8Array> { - const reader = body.getReader(); - return new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await reader.read(); - if (done) { - clearTimeout(timer); - controller.close(); - } else { - controller.enqueue(value); - } - } catch (err) { - clearTimeout(timer); - controller.error(err); - } - }, - cancel(reason) { - clearTimeout(timer); - return reader.cancel(reason); - }, - }); -} - -/** - * Reads a response's body as text, stopping at `maxBytes` (the rest is - * cancelled, never downloaded). - */ -export async function readTextCapped( - res: Response, - maxBytes = MAX_PAGE_BYTES, -): Promise<string> { - if (!res.body) return ""; - const reader = res.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - while (size < maxBytes) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - size += value.byteLength; - } - } finally { - await reader.cancel().catch(() => {}); - } - const bytes = new Uint8Array(Math.min(size, maxBytes)); - let offset = 0; - for (const chunk of chunks) { - const part = chunk.subarray(0, bytes.byteLength - offset); - bytes.set(part, offset); - offset += part.byteLength; - if (offset >= bytes.byteLength) break; - } - return new TextDecoder().decode(bytes); -} - -/** One request, no redirects followed. The timeout covers the body too. */ -async function fetchOnce( - url: URL, - address: string, - timeoutMs: number, -): Promise<Response> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - let res: Response; - try { - res = await fetch(url, { - signal: controller.signal, - headers: FETCH_HEADERS, - redirect: "manual", - }); - } catch (err) { - clearTimeout(timer); - if (!(err instanceof TypeError && err.message.includes("certificate"))) { - throw err; - } - - // Deno/rustls rejected the server's TLS certificate (expired, self-signed, - // or an unsupported signature algorithm) and offers no way to override - // verification programmatically. Fall back to a `curl --insecure` - // subprocess, which uses its own TLS stack. - const curlRes = await fetchViaCurl(url, address, timeoutMs); - if (curlRes) return curlRes; - - throw err; - } - if (!res.body) { - clearTimeout(timer); - return res; - } - return new Response(withDeadline(res.body, timer), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); -} - -/** - * Fetch a user-supplied URL. Every hop — the URL itself and each redirect — - * must resolve to a public address (see resolvePublicAddress), so a link or a - * redirect can't point the server at its own network. - */ -export async function fetchWithTimeout( - url: string, - timeoutMs = 5000, -): Promise<Response> { - let current = new URL(url); - for (let hop = 0;; hop++) { - if (current.protocol !== "http:" && current.protocol !== "https:") { - throw new TypeError(`Unsupported protocol ${current.protocol}`); - } - const address = await resolvePublicAddress(current); - const res = await fetchOnce(current, address, timeoutMs); - const location = res.headers.get("location"); - if (res.status < 300 || res.status >= 400 || !location) return res; - - await res.body?.cancel(); - if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects"); - current = new URL(location, current); - } -} - -function decodeHtmlEntities(str: string): string { - return str - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/'/gi, "'") - .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))) - .replace( - /&#x([0-9a-f]+);/gi, - (_, hex) => String.fromCodePoint(parseInt(hex, 16)), - ); -} - -/** - * Find the first `<meta>` tag whose `attr` equals `value` and return its - * `content` (either attribute ordering). Each tag is matched in isolation so - * the `content` capture can never span across tag boundaries. - */ -function extractMetaContent( - html: string, - attr: string, - value: string, -): string | undefined { - const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i"); - const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; - const tagRe = /<meta\b[^>]*>/gi; - let m: RegExpExecArray | null; - while ((m = tagRe.exec(html)) !== null) { - const tag = m[0]; - if (!keyRe.test(tag)) continue; - const content = contentRe.exec(tag); - if (content) return decodeHtmlEntities(content[2]); - } - return undefined; -} - -export function extractOgTag( - html: string, - tag: string, -): string | undefined { - return extractMetaContent(html, "property", `og:${tag}`); -} - -/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */ -export function extractMetaName( - html: string, - name: string, -): string | undefined { - return extractMetaContent(html, "name", name); -} - -/** Extract the text content of the `<title>` element. */ -export function extractPageTitle(html: string): string | undefined { - const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); - return match ? decodeHtmlEntities(match[1].trim()) : undefined; -} - -// ── Brand color helpers ─────────────────────────────────────────────────────── - -/** - * Find the page's `theme-color`. - * - * Sites routinely ship the tag twice, scoped by `media`, and often list the - * dark-scheme one first — so unlike `extractMetaName` this can't just take the - * first match. Prefer the unscoped tag, then the light-scheme one, then any. - */ -export function extractThemeColor(html: string): string | undefined { - const tagRe = /<meta\b[^>]*>/gi; - const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; - const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i; - let unscoped: string | undefined; - let light: string | undefined; - let any: string | undefined; - - let m: RegExpExecArray | null; - while ((m = tagRe.exec(html)) !== null) { - const tag = m[0]; - if (!/\bname=["']theme-color["']/i.test(tag)) continue; - const content = contentRe.exec(tag)?.[2]; - if (!content) continue; - const media = mediaRe.exec(tag)?.[2]; - if (!media) unscoped ??= content; - else if (/light/i.test(media)) light ??= content; - any ??= content; - } - return unscoped ?? light ?? any; -} - -/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ -export function extractMaskIconColor(html: string): string | undefined { - const linkRe = /<link[^>]+>/gi; - let m: RegExpExecArray | null; - while ((m = linkRe.exec(html)) !== null) { - const tag = m[0]; - if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue; - const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2]; - if (color) return color; - } - return undefined; -} - -// The handful of named colors that actually show up in `theme-color`. The full -// 148-name table isn't worth carrying for the long tail. -const NAMED_COLORS: Record<string, string> = { - black: "#000000", - white: "#ffffff", - red: "#ff0000", - green: "#008000", - blue: "#0000ff", - yellow: "#ffff00", - orange: "#ffa500", - purple: "#800080", - gray: "#808080", - grey: "#808080", - silver: "#c0c0c0", - maroon: "#800000", - navy: "#000080", - teal: "#008080", - olive: "#808000", - lime: "#00ff00", - aqua: "#00ffff", - cyan: "#00ffff", - fuchsia: "#ff00ff", - magenta: "#ff00ff", -}; - -function clampByte(n: number): number { - return Math.max(0, Math.min(255, Math.round(n))); -} - -/** - * Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when - * it isn't one of the forms sites actually use. Alpha is dropped — the color is - * only ever used as a tint over the app's own surface. - */ -export function normalizeCssColor(raw: string | undefined): string | undefined { - if (!raw) return undefined; - const value = raw.trim().toLowerCase(); - - const named = NAMED_COLORS[value]; - if (named) return named; - - const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1]; - if (hex) { - if (hex.length === 3 || hex.length === 4) { - const [r, g, b] = [...hex.slice(0, 3)]; - return `#${r}${r}${g}${g}${b}${b}`; - } - if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`; - return undefined; - } - - const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1]; - if (rgb) { - const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3); - if (parts.length !== 3) return undefined; - const bytes = parts.map((p) => { - const n = parseFloat(p); - if (!Number.isFinite(n)) return NaN; - return clampByte(p.endsWith("%") ? (n / 100) * 255 : n); - }); - if (bytes.some(Number.isNaN)) return undefined; - return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`; - } - - return undefined; -} - -// ── JSON-LD helpers (file-private) ──────────────────────────────────────────── - -type JsonLdResult = { - title?: string; - description?: string; - thumbnailUrl?: string; -}; - -function ldString(v: unknown): string | undefined { - if (typeof v === "string" && v.trim()) return v.trim(); - if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) { - return v[0].trim(); - } - return undefined; -} - -function ldImage(v: unknown): string | undefined { - if ( - typeof v === "string" && - (v.startsWith("http://") || v.startsWith("https://")) - ) return v; - if (Array.isArray(v)) return ldImage(v[0]); - if (v && typeof v === "object") { - const o = v as Record<string, unknown>; - return ldImage(o.url ?? o.contentUrl); - } - return undefined; -} - -function ldExtractNode(data: unknown): JsonLdResult { - if (Array.isArray(data)) { - for (const item of data) { - const r = ldExtractNode(item); - if (r.title || r.thumbnailUrl) return r; - } - return {}; - } - if (!data || typeof data !== "object") return {}; - const o = data as Record<string, unknown>; - if (o["@graph"]) return ldExtractNode(o["@graph"]); - return { - title: ldString(o.name ?? o.headline), - description: ldString(o.description), - thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail), - }; -} - -/** - * Parse every `<script type="application/ld+json">` block and return the first - * node that yields a title or image. Handles `@graph`, arrays, and the common - * `image` shapes (string, string[], ImageObject). - */ -export function extractJsonLd(html: string): JsonLdResult { - const pattern = - /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; - let match: RegExpExecArray | null; - while ((match = pattern.exec(html)) !== null) { - try { - const result = ldExtractNode(JSON.parse(match[1])); - if (result.title || result.thumbnailUrl) return result; - } catch { /* invalid JSON — skip */ } - } - return {}; -} - -/** - * Return the `src` of the first `<img>` whose declared width or height is at - * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. - */ -export function extractLargeImage( - html: string, - baseUrl: string, - minSize = 200, -): string | undefined { - const imgPattern = /<img[^>]+>/gi; - let match: RegExpExecArray | null; - while ((match = imgPattern.exec(html)) !== null) { - const tag = match[0]; - const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; - if (!src || src.startsWith("data:")) continue; - const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); - const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); - if (w >= minSize && h >= minSize) { - try { - return new URL(src, baseUrl).toString(); - } catch { - continue; - } - } - } - return undefined; -} - -/** - * Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank - * them by declared size (largest wins), and return the best resolved URL. - * Falls back to the first match when no `sizes` attribute is present. - * - * SVG icons are ranked above everything: they carry no `sizes` attribute, so - * they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to - * whatever size the placeholder renders them at. - */ -export function extractBestIcon( - html: string, - baseUrl: string, -): string | undefined { - const linkRe = /<link[^>]+>/gi; - const relRe = /\brel=["']([^"']+)["']/i; - const hrefRe = /\bhref=["']([^"']+)["']/i; - const sizesRe = /\bsizes=["']([^"']+)["']/i; - - const candidates: { href: string; area: number }[] = []; - - let m: RegExpExecArray | null; - while ((m = linkRe.exec(html)) !== null) { - const tag = m[0]; - const rel = relRe.exec(tag)?.[1] ?? ""; - if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue; - const href = hrefRe.exec(tag)?.[1]; - if (!href) continue; - const sizesStr = sizesRe.exec(tag)?.[1] ?? ""; - const sm = sizesStr.match(/(\d+)x(\d+)/i); - const isSvg = /\.svg(\?|$)/i.test(href) || - /\btype=["']image\/svg\+xml["']/i.test(tag); - const area = isSvg - ? Number.MAX_SAFE_INTEGER - : sm - ? parseInt(sm[1]) * parseInt(sm[2]) - : 0; - try { - candidates.push({ href: new URL(href, baseUrl).toString(), area }); - } catch { - continue; - } - } - - if (candidates.length === 0) return undefined; - candidates.sort((a, b) => b.area - a.area); - return candidates[0].href; -} - -/** - * Return the `src` of the first `<img>` that looks like content rather than UI - * chrome. Skips SVGs, data URIs, and images whose filename matches common - * icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.). - * Resolves relative and protocol-relative URLs. - */ -const UI_IMAGE_KEYWORDS = new Set([ - "logo", - "icon", - "sprite", - "favicon", - "avatar", - "banner", - "header", - "nav", - "menu", - "cart", - "search", - "tracking", - "pixel", - "bg", - "background", -]); - -function isUiImage(src: string): boolean { - if (/\.svg(\?|$)/i.test(src)) return true; - const filename = src.split("?")[0].split("/").pop() ?? ""; - const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension - // Split on common filename separators (-, _, .) and check each token - return baseName.toLowerCase().split(/[-_.]/).some((t) => - UI_IMAGE_KEYWORDS.has(t) - ); -} - -/** - * Return the `src` of the first `<img>` that looks like content rather than UI - * chrome. Skips SVGs, data URIs, and images whose filename tokens match common - * icon/logo/nav patterns (logo, icon, sprite, etc.). - * Resolves relative and protocol-relative URLs. - */ -export function extractFirstContentImage( - html: string, - baseUrl: string, -): string | undefined { - const imgPattern = /<img[^>]+>/gi; - let match: RegExpExecArray | null; - while ((match = imgPattern.exec(html)) !== null) { - const tag = match[0]; - const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; - if (!src || src.startsWith("data:")) continue; - if (isUiImage(src)) continue; - try { - return new URL(src, baseUrl).toString(); - } catch { - continue; - } - } - return undefined; -} - -/** - * Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`, - * resolved to an absolute URL using `baseUrl`. - */ -export function extractLinkHref( - html: string, - relFragment: string, - baseUrl: string, -): string | undefined { - const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const patterns = [ - new RegExp( - `<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`, - "i", - ), - new RegExp( - `<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`, - "i", - ), - ]; - for (const pattern of patterns) { - const match = html.match(pattern); - if (match) { - try { - return new URL(match[1], baseUrl).toString(); - } catch { - return undefined; - } - } - } - return undefined; -} - -export function isValidHttpUrl(raw: string): boolean { - try { - const u = new URL(raw); - if (u.protocol !== "http:" && u.protocol !== "https:") return false; - // This site's own pages are fine even on a private host (e.g. localhost in - // development): selfProvider reads them from the DB, no HTTP involved. - // Every actual fetch is checked again, after DNS, in fetchWithTimeout. - return !isPrivateHostname(u) || isSelfUrl(u); - } catch { - return false; - } -} - export interface FetchRichContentResult { /** False when the page couldn't be reached at all, as opposed to reached and * found to carry no metadata. Callers that already hold good metadata must diff --git a/api/services/user-service.ts b/api/services/user-service.ts index b86a009b..3bac8f44 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -1,15 +1,21 @@ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import type { + Invitee, + PublicUser, + RegisterUserRequest, + Role, + UpdateUserRequest, + UserSuggestion, +} from "../../shared/user.ts"; import { - APIErrorCode, - APIException, - type Invitee, - type PublicUser, - type RegisterUserRequest, + isUserRow, toPublicUser, - type UpdateUserRequest, type User, - type UserSuggestion, -} from "../model/interfaces.ts"; -import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; + userApiToRow, + userRowToApi, +} from "../model/user.ts"; +import { db } from "../db/connection.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; @@ -92,6 +98,25 @@ export function getUserById(userId: string): User { return userRowToApi(userRow); } +/** + * What a session token is checked against: the user's live role, and the + * moment older tokens stopped counting (their last password change). Undefined + * when the user is gone. + */ +export function getSessionState( + userId: string, +): { role: Role; tokensValidAfter: string | null } | undefined { + const row = db.prepare( + `SELECT role, tokens_valid_after FROM users WHERE id = ?;`, + ).get(userId) as + | { role: Role; tokens_valid_after: string | null } + | undefined; + return row && { + role: row.role, + tokensValidAfter: row.tokens_valid_after, + }; +} + export function getUserByUsername(username: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE u.username = ?`, diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 950ad611..d04deaad 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -1,5 +1,7 @@ -import { APIErrorCode, APIException, type User } from "../model/interfaces.ts"; -import { db, isUserRow, userRowToApi } from "../model/db.ts"; +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"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index d464ff76..ee36cd0a 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,13 +1,9 @@ -import type { - ChatMessage, - Comment, - Dump, - OnlineUser, - Playlist, - PublicUser, - Role, - ServerToClientMessage, -} from "../model/interfaces.ts"; +import type { ChatMessage } from "../../shared/chat.ts"; +import type { Comment } from "../../shared/comment.ts"; +import type { Dump } from "../../shared/dump.ts"; +import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts"; +import type { Playlist } from "../../shared/playlist.ts"; +import type { PublicUser, Role } from "../../shared/user.ts"; import { isMemberVisibleTo, type PlaylistMember, diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts new file mode 100644 index 00000000..08abbd11 --- /dev/null +++ b/api/test/db/migrate.test.ts @@ -0,0 +1,41 @@ +import "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { runMigrations } from "../../db/migrate.ts"; + +const SCHEMA = Deno.readTextFileSync( + new URL("../../db/schema.sql", import.meta.url), +); + +function freshDb(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(SCHEMA); + return db; +} + +function schemaOf(db: DatabaseSync): string[] { + return (db.prepare( + `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`, + ).all() as { sql: string }[]).map((r) => r.sql); +} + +Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => { + const db = freshDb(); + const before = schemaOf(db); + runMigrations(db); + assertEquals(schemaOf(db), before); + const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`) + .get() as { + n: number; + }; + assertEquals(applied.n, 16); + // Running again applies nothing. + runMigrations(db); + assertEquals( + (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { + n: number; + }).n, + 16, + ); + db.close(); +}); diff --git a/api/test/db/migrations/0005_drop_is_admin.test.ts b/api/test/db/migrations/0005_drop_is_admin.test.ts new file mode 100644 index 00000000..e5bd56ea --- /dev/null +++ b/api/test/db/migrations/0005_drop_is_admin.test.ts @@ -0,0 +1,28 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0004 } from "../../../db/migrations/0004_user_roles.ts"; +import { up as up0005 } from "../../../db/migrations/0005_drop_is_admin.ts"; + +Deno.test("0004/0005: is_admin becomes role, then goes", () => { + const db = new DatabaseSync(":memory:"); + db.exec( + `CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`, + ); + db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`); + up0004(db); + up0004(db); + up0005(db); + up0005(db); + assertEquals( + db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({ + ...r, + })), + [{ id: "a", role: "admin" }, { id: "b", role: "user" }], + ); + const cols = + (db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[]) + .map((c) => c.name); + assertEquals(cols, ["id", "role"]); + db.close(); +}); diff --git a/api/test/db/migrations/0010_youtube_embed_start.test.ts b/api/test/db/migrations/0010_youtube_embed_start.test.ts new file mode 100644 index 00000000..0c6ef1df --- /dev/null +++ b/api/test/db/migrations/0010_youtube_embed_start.test.ts @@ -0,0 +1,67 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0010 } from "../../../db/migrations/0010_youtube_embed_start.ts"; + +const SCHEMA = Deno.readTextFileSync( + new URL("../../../db/schema.sql", import.meta.url), +); + +function freshDb(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(SCHEMA); + return db; +} + +function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) { + db.prepare( + `INSERT INTO users (id, username, password_hash, created_at, email) + VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`, + ).run(); + db.prepare( + `INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content) + VALUES (?, 'url', 't', 'u', 'now', ?, ?);`, + ).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich)); +} + +function richOf(db: DatabaseSync, id: string): Record<string, unknown> { + return JSON.parse( + (db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as { + rich_content: string; + }).rich_content, + ); +} + +Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => { + const db = freshDb(); + const embed = "https://www.youtube.com/embed/abc?rel=0"; + insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "plain", "https://youtu.be/abc?start=42", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "none", "https://youtu.be/abc", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "has", "https://youtu.be/abc?t=5", { + type: "youtube", + embedUrl: `${embed}&start=9`, + }); + insertRich(db, "other", "https://example.com/?t=5", { + type: "generic", + embedUrl: embed, + }); + insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json"); + up0010(db); + up0010(db); + assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`); + assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`); + assertEquals(richOf(db, "none").embedUrl, embed); + assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`); + assertEquals(richOf(db, "other").embedUrl, embed); + db.close(); +}); diff --git a/api/test/db/migrations/0012_fix_favicon_reclassification.test.ts b/api/test/db/migrations/0012_fix_favicon_reclassification.test.ts new file mode 100644 index 00000000..b44483b9 --- /dev/null +++ b/api/test/db/migrations/0012_fix_favicon_reclassification.test.ts @@ -0,0 +1,91 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0011 } from "../../../db/migrations/0011_split_favicon_thumbnail.ts"; +import { up as up0012 } from "../../../db/migrations/0012_fix_favicon_reclassification.ts"; + +const SCHEMA = Deno.readTextFileSync( + new URL("../../../db/schema.sql", import.meta.url), +); + +function freshDb(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(SCHEMA); + return db; +} + +function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) { + db.prepare( + `INSERT INTO users (id, username, password_hash, created_at, email) + VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`, + ).run(); + db.prepare( + `INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content) + VALUES (?, 'url', 't', 'u', 'now', ?, ?);`, + ).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich)); +} + +function richOf(db: DatabaseSync, id: string): Record<string, unknown> { + return JSON.parse( + (db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as { + rich_content: string; + }).rich_content, + ); +} + +Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => { + const db = freshDb(); + const cases: Record< + string, + [string, string | undefined, string | undefined] + > = { + // id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after] + ico: [ + "https://a.test/favicon.ico", + undefined, + "https://a.test/favicon.ico", + ], + touch: [ + "https://a.test/apple-touch-icon.png", + undefined, + "https://a.test/apple-touch-icon.png", + ], + art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined], + // 0011 mistook these for icons; 0012 puts them back. + logoFavicon: [ + "https://a.test/uploads/mir-logo-favicon.png", + "https://a.test/uploads/mir-logo-favicon.png", + undefined, + ], + svgArt: [ + "https://a.test/art/poster.svg", + "https://a.test/art/poster.svg", + undefined, + ], + // 0011 missed this one; 0012 catches it. + iconName: [ + "https://a.test/img/icon_SEARCH.png", + undefined, + "https://a.test/img/icon_SEARCH.png", + ], + }; + for (const [id, [thumbnailUrl]] of Object.entries(cases)) { + insertRich(db, id, "https://a.test/", { + type: "generic", + url: "u", + thumbnailUrl, + }); + } + up0011(db); + up0011(db); + up0012(db); + up0012(db); + for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) { + const rich = richOf(db, id); + assertEquals([rich.thumbnailUrl, rich.faviconUrl], [ + thumbnailUrl, + faviconUrl, + ], id); + } + db.close(); +}); diff --git a/api/test/db/migrations/0013_dump_url_canonical.test.ts b/api/test/db/migrations/0013_dump_url_canonical.test.ts new file mode 100644 index 00000000..88f4ccfc --- /dev/null +++ b/api/test/db/migrations/0013_dump_url_canonical.test.ts @@ -0,0 +1,31 @@ +import "../../setup.ts"; +import { assertEquals } from "@std/assert"; +import { canonicalizeUrl } from "../../../lib/canonical-url.ts"; +import { up as up0013 } from "../../../db/migrations/0013_dump_url_canonical.ts"; +import { DatabaseSync } from "node:sqlite"; + +// Migration 0013 carries a frozen copy of the canonicalizer to backfill old +// rows. Until the rules change on purpose (with a new backfill migration), the +// two must agree, or rows from before and after the migration won't match. +Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => { + const urls = [ + "http://www.example.com/a/?utm_source=x&b=2&a=1#top", + "https://youtu.be/abc123?t=5", + "https://www.youtube.com/playlist?list=PLx", + "https://app.example.com/#/route", + ]; + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`); + urls.forEach((url, i) => + db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url) + ); + up0013(db); + up0013(db); // idempotent + const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`) + .all() as { id: string; url_canonical: string }[]; + assertEquals( + rows.map((r) => r.url_canonical), + urls.map(canonicalizeUrl), + ); + db.close(); +}); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 1b3d07e9..42fdccca 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -3,13 +3,14 @@ import { TEST_DIR } from "./setup.ts"; import { stub } from "@std/testing/mock"; import { app } from "../main.ts"; -import { db } from "../model/db.ts"; +import { db } from "../db/connection.ts"; import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts"; import { makeSlug } from "../lib/slugify.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; -import { clearImageCache } from "../routes/preview.ts"; -import type { APIError, APIResponse, Role } from "../model/interfaces.ts"; +import { clearImageCache } from "../remote/image-proxy.ts"; +import type { APIError, APIResponse } from "../../shared/api.ts"; +import type { Role } from "../../shared/user.ts"; export { app, db, TEST_DIR }; diff --git a/api/test/lib.test.ts b/api/test/lib.test.ts deleted file mode 100644 index 157942cd..00000000 --- a/api/test/lib.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Small pure helpers: slugs, pagination, permissions, rate limiting, image -// sniffing, self-links and the favicon codepoint. -import "./setup.ts"; -import { - assert, - assertEquals, - assertFalse, - assertMatch, - assertThrows, -} from "@std/assert"; -import { FakeTime } from "@std/testing/time"; -import { makeSlug, slugify, UUID_RE } from "../lib/slugify.ts"; -import { - isReservedSlug, - isSlugAvailable, - isValidSlugFormat, -} from "../lib/reserved-slugs.ts"; -import { parsePagination } from "../lib/pagination.ts"; -import { can, hasPermission, ROLE_PERMISSIONS } from "../lib/permissions.ts"; -import { takeToken } from "../lib/rate-limit.ts"; -import { detectImageMime, validateImageUpload } from "../lib/upload.ts"; -import { isSelfUrl } from "../lib/self-url.ts"; -import { emojiToCodepoint } from "../lib/site-icons.ts"; -import { APIException } from "../model/interfaces.ts"; -import { PNG } from "./helpers.ts"; - -Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { - assertEquals(slugify("Hello, World!"), "hello-world"); - assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); - assertEquals(slugify(" --already--dashed-- "), "already-dashed"); - assertEquals(slugify("日本語"), "untitled"); - assertEquals(slugify(""), "untitled"); - assertEquals(slugify("x".repeat(100)).length, 60); -}); - -Deno.test("makeSlug: suffixes the first 8 characters of the id", () => { - const id = "0123abcd-0000-4000-8000-000000000000"; - assertEquals(makeSlug("My Song", id), "my-song-0123abcd"); - assert(UUID_RE.test(id)); - assertFalse(UUID_RE.test("my-song-0123abcd")); -}); - -Deno.test("category slugs: format and reserved words", () => { - assert(isValidSlugFormat("music")); - assert(isValidSlugFormat("lo-fi-2")); - for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { - assertFalse(isValidSlugFormat(bad), bad); - } - assert(isReservedSlug("dumps")); - assert(isReservedSlug("api")); - assertFalse(isReservedSlug("music")); - assert(isSlugAvailable("music")); - assertFalse(isSlugAvailable("users")); - assertFalse(isSlugAvailable("Nope")); -}); - -Deno.test("parsePagination: defaults, bounds and garbage", () => { - const p = (q: string, def?: number) => - parsePagination(new URLSearchParams(q), def); - assertEquals(p(""), { page: 1, limit: 20 }); - assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 }); - assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 }); - assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 }); - assertEquals(p("limit=1000"), { page: 1, limit: 100 }); - assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 }); - assertEquals(p("", 7), { page: 1, limit: 7 }); -}); - -Deno.test("permissions: admin has all, moderator moderates, user nothing", () => { - for (const perm of ROLE_PERMISSIONS.admin) { - assert(hasPermission("admin", perm)); - } - assert(hasPermission("moderator", "dump:moderate")); - assert(hasPermission("moderator", "chat:moderate")); - assertFalse(hasPermission("moderator", "user:manage")); - assertFalse(hasPermission("moderator", "category:manage")); - assertEquals(ROLE_PERMISSIONS.user, []); - assert( - can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"), - ); - assertFalse( - can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"), - ); -}); - -Deno.test("takeToken: bursts to capacity, then refills over time", () => { - using time = new FakeTime(); - const key = `test:${crypto.randomUUID()}`; - const limit = { capacity: 3, refill: 1 }; // one token a second - assert(takeToken(key, limit)); - assert(takeToken(key, limit)); - assert(takeToken(key, limit)); - assertFalse(takeToken(key, limit)); - time.tick(500); - assertFalse(takeToken(key, limit)); - time.tick(500); - assert(takeToken(key, limit)); - assertFalse(takeToken(key, limit)); - // Refill never exceeds capacity. - time.tick(60_000); - assert(takeToken(key, limit)); - assert(takeToken(key, limit)); - assert(takeToken(key, limit)); - assertFalse(takeToken(key, limit)); - // Keys are independent. - assert(takeToken(`${key}:other`, limit)); -}); - -Deno.test("detectImageMime: sniffs magic bytes, not names", () => { - assertEquals(detectImageMime(PNG), "image/png"); - assertEquals( - detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), - "image/jpeg", - ); - assertEquals( - detectImageMime(new TextEncoder().encode("GIF89a")), - "image/gif", - ); - assertEquals( - detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), - "image/webp", - ); - assertEquals( - detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")), - null, - ); - assertEquals( - detectImageMime(new TextEncoder().encode("<svg xmlns=...>")), - null, - ); - assertEquals(detectImageMime(new Uint8Array()), null); -}); - -Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => { - assertEquals(validateImageUpload(PNG), "image/png"); - assertThrows( - () => validateImageUpload(new TextEncoder().encode("<html>")), - APIException, - "not a recognised image", - ); - const big = new Uint8Array(5 * 1024 * 1024 + 1); - big.set(PNG); - assertThrows(() => validateImageUpload(big), APIException, "too large"); -}); - -Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => { - // GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite. - assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); - assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob"))); - assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1"))); - assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit"))); - assertFalse(isSelfUrl(new URL("http://gerbeur.test/search"))); - assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob"))); -}); - -Deno.test("emojiToCodepoint: Twemoji file naming", () => { - assertEquals(emojiToCodepoint("🚚"), "1f69a"); - // A lone variation selector is dropped… - assertEquals(emojiToCodepoint("❤️"), "2764"); - // …but kept inside a ZWJ sequence. - assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/); -}); diff --git a/api/test/lib/auth.test.ts b/api/test/lib/auth.test.ts new file mode 100644 index 00000000..5b33af99 --- /dev/null +++ b/api/test/lib/auth.test.ts @@ -0,0 +1,62 @@ +import { createUser, db, get, test } from "../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { authenticate, authenticateMedia } from "../../lib/auth.ts"; + +test("authenticate: the role comes from the database, not the token", async () => { + const user = await createUser({ role: "user" }); + db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id); + assertEquals((await authenticate(user.token))?.role, "admin"); +}); + +test("authenticate: a deleted user's token is refused", async () => { + const user = await createUser(); + db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id); + assertEquals(await authenticate(user.token), null); + assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); +}); + +test("authenticate: tokens issued before tokens_valid_after are revoked", async () => { + const user = await createUser(); + const future = new Date(Date.now() + 60_000).toISOString(); + db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( + future, + user.id, + ); + assertEquals(await authenticate(user.token), null); + assertEquals(await authenticateMedia(user.mediaToken), null); + // A token issued at the cutoff second or later passes. + const past = new Date(Date.now() - 60_000).toISOString(); + db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( + past, + user.id, + ); + assertEquals((await authenticate(user.token))?.userId, user.id); +}); + +test("authenticateMedia: takes a media token or a legacy session token", async () => { + const user = await createUser(); + assertEquals(await authenticateMedia(user.mediaToken), user.id); + assertEquals(await authenticateMedia(user.token), user.id); + assertEquals(await authenticateMedia("garbage"), null); +}); + +test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => { + const owner = await createUser(); + // A public listing that reads optional auth. + const path = `/api/users/${owner.username}/dumps`; + assertEquals((await get(path)).status, 200); + for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) { + assertEquals( + (await get(path, { headers: { Authorization: placeholder } })).status, + 200, + placeholder, + ); + } + assertEquals( + (await get(path, { headers: { Authorization: "Basic abc" } })).status, + 200, + ); + const bad = await get(path, { headers: { Authorization: "Bearer nope" } }); + assertEquals(bad.status, 401); + assertEquals(bad.error.code, "UNAUTHORIZED"); +}); diff --git a/api/test/canonical-url.test.ts b/api/test/lib/canonical-url.test.ts similarity index 66% rename from api/test/canonical-url.test.ts rename to api/test/lib/canonical-url.test.ts index 32bd478f..dbe572d7 100644 --- a/api/test/canonical-url.test.ts +++ b/api/test/lib/canonical-url.test.ts @@ -1,8 +1,6 @@ -import "./setup.ts"; +import "../setup.ts"; import { assertEquals } from "@std/assert"; -import { canonicalizeUrl } from "../lib/canonical-url.ts"; -import { up as up0013 } from "../db/migrations/0013_dump_url_canonical.ts"; -import { DatabaseSync } from "node:sqlite"; +import { canonicalizeUrl } from "../../lib/canonical-url.ts"; Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => { const want = "https://example.com/a"; @@ -76,29 +74,3 @@ Deno.test("canonicalizeUrl: non-http and unparseable input", () => { assertEquals(canonicalizeUrl("not a url"), null); assertEquals(canonicalizeUrl(""), null); }); - -// Migration 0013 carries a frozen copy of the canonicalizer to backfill old -// rows. Until the rules change on purpose (with a new backfill migration), the -// two must agree, or rows from before and after the migration won't match. -Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => { - const urls = [ - "http://www.example.com/a/?utm_source=x&b=2&a=1#top", - "https://youtu.be/abc123?t=5", - "https://www.youtube.com/playlist?list=PLx", - "https://app.example.com/#/route", - ]; - const db = new DatabaseSync(":memory:"); - db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`); - urls.forEach((url, i) => - db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url) - ); - up0013(db); - up0013(db); // idempotent - const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`) - .all() as { id: string; url_canonical: string }[]; - assertEquals( - rows.map((r) => r.url_canonical), - urls.map(canonicalizeUrl), - ); - db.close(); -}); diff --git a/api/test/lib/jwt.test.ts b/api/test/lib/jwt.test.ts new file mode 100644 index 00000000..92b870ed --- /dev/null +++ b/api/test/lib/jwt.test.ts @@ -0,0 +1,80 @@ +import "../setup.ts"; +import { + assert, + assertEquals, + assertFalse, + assertNotEquals, +} 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" }); + const media = await createMediaToken(userId); + const reset = await createPasswordResetToken(userId); + + assertEquals((await verifyJWT(session))?.userId, userId); + assertEquals(await verifyJWT(media), null); + assertEquals(await verifyJWT(reset), null); + + assertEquals((await verifyMediaToken(media))?.userId, userId); + assertEquals(await verifyMediaToken(session), null); + assertEquals(await verifyMediaToken(reset), null); + + assertEquals((await verifyPasswordResetToken(reset))?.userId, userId); + assertEquals(await verifyPasswordResetToken(session), null); + assertEquals(await verifyPasswordResetToken(media), null); +}); + +Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => { + const forged = await new SignJWT({ + userId: "x", + username: "x", + role: "admin", + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode("some-other-secret")); + assertEquals(await verifyJWT(forged), null); + + const expired = await new SignJWT({ + userId: "x", + username: "x", + role: "user", + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(Math.floor(Date.now() / 1000) - 10) + .sign(KEY); + assertEquals(await verifyJWT(expired), null); + + const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(KEY); + assertEquals(await verifyJWT(badRole), null); + + assertEquals(await verifyJWT("not.a.jwt"), null); +}); diff --git a/api/test/lib/pagination.test.ts b/api/test/lib/pagination.test.ts new file mode 100644 index 00000000..7f64db89 --- /dev/null +++ b/api/test/lib/pagination.test.ts @@ -0,0 +1,15 @@ +import "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { parsePagination } from "../../lib/pagination.ts"; + +Deno.test("parsePagination: defaults, bounds and garbage", () => { + const p = (q: string, def?: number) => + parsePagination(new URLSearchParams(q), def); + assertEquals(p(""), { page: 1, limit: 20 }); + assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 }); + assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 }); + assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 }); + assertEquals(p("limit=1000"), { page: 1, limit: 100 }); + assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 }); + assertEquals(p("", 7), { page: 1, limit: 7 }); +}); diff --git a/api/test/lib/permissions.test.ts b/api/test/lib/permissions.test.ts new file mode 100644 index 00000000..6d14db1e --- /dev/null +++ b/api/test/lib/permissions.test.ts @@ -0,0 +1,12 @@ +import "../setup.ts"; +import { assert, assertFalse } from "@std/assert"; +import { can } from "../../lib/permissions.ts"; + +Deno.test("can: checks the token's role", () => { + assert( + can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"), + ); + assertFalse( + can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"), + ); +}); diff --git a/api/test/lib/rate-limit.test.ts b/api/test/lib/rate-limit.test.ts new file mode 100644 index 00000000..7f686590 --- /dev/null +++ b/api/test/lib/rate-limit.test.ts @@ -0,0 +1,27 @@ +import "../setup.ts"; +import { assert, assertFalse } from "@std/assert"; +import { FakeTime } from "@std/testing/time"; +import { takeToken } from "../../lib/rate-limit.ts"; + +Deno.test("takeToken: bursts to capacity, then refills over time", () => { + using time = new FakeTime(); + const key = `test:${crypto.randomUUID()}`; + const limit = { capacity: 3, refill: 1 }; // one token a second + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + time.tick(500); + assertFalse(takeToken(key, limit)); + time.tick(500); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + // Refill never exceeds capacity. + time.tick(60_000); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + // Keys are independent. + assert(takeToken(`${key}:other`, limit)); +}); diff --git a/api/test/lib/reserved-slugs.test.ts b/api/test/lib/reserved-slugs.test.ts new file mode 100644 index 00000000..95b922a9 --- /dev/null +++ b/api/test/lib/reserved-slugs.test.ts @@ -0,0 +1,21 @@ +import "../setup.ts"; +import { assert, assertFalse } from "@std/assert"; +import { + isReservedSlug, + isSlugAvailable, + isValidSlugFormat, +} from "../../lib/reserved-slugs.ts"; + +Deno.test("category slugs: format and reserved words", () => { + assert(isValidSlugFormat("music")); + assert(isValidSlugFormat("lo-fi-2")); + for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { + assertFalse(isValidSlugFormat(bad), bad); + } + assert(isReservedSlug("dumps")); + assert(isReservedSlug("api")); + assertFalse(isReservedSlug("music")); + assert(isSlugAvailable("music")); + assertFalse(isSlugAvailable("users")); + assertFalse(isSlugAvailable("Nope")); +}); diff --git a/api/test/lib/self-url.test.ts b/api/test/lib/self-url.test.ts new file mode 100644 index 00000000..b8676102 --- /dev/null +++ b/api/test/lib/self-url.test.ts @@ -0,0 +1,13 @@ +import "../setup.ts"; +import { assert, assertFalse } from "@std/assert"; +import { isSelfUrl } from "../../lib/self-url.ts"; + +Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => { + // GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite. + assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); + assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob"))); + assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/search"))); + assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob"))); +}); diff --git a/api/test/lib/site-icons.test.ts b/api/test/lib/site-icons.test.ts new file mode 100644 index 00000000..1ff2b83b --- /dev/null +++ b/api/test/lib/site-icons.test.ts @@ -0,0 +1,11 @@ +import "../setup.ts"; +import { assertEquals, assertMatch } from "@std/assert"; +import { emojiToCodepoint } from "../../lib/site-icons.ts"; + +Deno.test("emojiToCodepoint: Twemoji file naming", () => { + assertEquals(emojiToCodepoint("🚚"), "1f69a"); + // A lone variation selector is dropped… + assertEquals(emojiToCodepoint("❤️"), "2764"); + // …but kept inside a ZWJ sequence. + assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/); +}); diff --git a/api/test/lib/slugify.test.ts b/api/test/lib/slugify.test.ts new file mode 100644 index 00000000..8a6ff144 --- /dev/null +++ b/api/test/lib/slugify.test.ts @@ -0,0 +1,19 @@ +import "../setup.ts"; +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts"; + +Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { + assertEquals(slugify("Hello, World!"), "hello-world"); + assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); + assertEquals(slugify(" --already--dashed-- "), "already-dashed"); + assertEquals(slugify("日本語"), "untitled"); + assertEquals(slugify(""), "untitled"); + assertEquals(slugify("x".repeat(100)).length, 60); +}); + +Deno.test("makeSlug: suffixes the first 8 characters of the id", () => { + const id = "0123abcd-0000-4000-8000-000000000000"; + assertEquals(makeSlug("My Song", id), "my-song-0123abcd"); + assert(UUID_RE.test(id)); + assertFalse(UUID_RE.test("my-song-0123abcd")); +}); diff --git a/api/test/lib/static.test.ts b/api/test/lib/static.test.ts new file mode 100644 index 00000000..b620f4e2 --- /dev/null +++ b/api/test/lib/static.test.ts @@ -0,0 +1,74 @@ +import { BASE_URL, TEST_DIR } from "../helpers.ts"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; +import { Application } from "@oak/oak"; +import { routeStaticFilesFrom } from "../../lib/static.ts"; + +async function handle( + app: Application, + path: string, + headers: HeadersInit = {}, +) { + const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers })); + if (!res) throw new Error("no response"); + return { res, text: await res.text() }; +} + +Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => { + const root = `${TEST_DIR}/static-${crypto.randomUUID()}`; + await Deno.mkdir(`${root}/assets`, { recursive: true }); + await Deno.writeTextFile( + `${root}/index.html`, + `<html><head><title>__SITE_NAME____BANDCAMP_PLAYER__`, + ); + const js = "console.log('app');".repeat(50); + await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js); + const br = new Uint8Array([1, 2, 3]); + await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br); + await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *"); + + const app = new Application(); + app.use(routeStaticFilesFrom([root])); + + const index = await handle(app, "/"); + assertEquals(index.res.headers.get("cache-control"), "no-cache"); + assertEquals( + index.text, + "gerbeurembed", + ); + + const spa = await handle(app, "/users/someone"); + assertEquals(spa.res.status, 200); + assertEquals(spa.text, index.text); + + // No Accept-Encoding header: the plain file, not the .br sibling. + const asset = await handle(app, "/assets/app-abc123.js"); + assertEquals(asset.text, js); + assertEquals(asset.res.headers.get("content-encoding"), null); + assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable"); + assertStringIncludes( + asset.res.headers.get("cache-control")!, + "max-age=31536000", + ); + assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding"); + + const compressed = await handle(app, "/assets/app-abc123.js", { + "Accept-Encoding": "br, gzip", + }); + assertEquals(compressed.res.headers.get("content-encoding"), "br"); + assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes + + const plain = await handle(app, "/robots.txt"); + assertEquals(plain.text, "User-agent: *"); + assertFalse( + (plain.res.headers.get("cache-control") ?? "").includes("immutable"), + ); + + // A chunk from an older build 404s rather than being answered with the index. + const stale = await handle(app, "/assets/app-old999.js"); + assertEquals(stale.res.status, 404); + assertFalse(stale.text.includes("")); + assertEquals( + (await handle(app, "/emojibase/17.0.0/missing.json")).res.status, + 404, + ); +}); diff --git a/api/test/lib/upload.test.ts b/api/test/lib/upload.test.ts new file mode 100644 index 00000000..0ff80e84 --- /dev/null +++ b/api/test/lib/upload.test.ts @@ -0,0 +1,42 @@ +import "../setup.ts"; +import { PNG } from "../helpers.ts"; +import { assertEquals, assertThrows } from "@std/assert"; +import { detectImageMime, validateImageUpload } from "../../lib/upload.ts"; +import { APIException } from "../../lib/api-exception.ts"; + +Deno.test("detectImageMime: sniffs magic bytes, not names", () => { + assertEquals(detectImageMime(PNG), "image/png"); + assertEquals( + detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), + "image/jpeg", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("GIF89a")), + "image/gif", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), + "image/webp", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")), + null, + ); + assertEquals( + detectImageMime(new TextEncoder().encode("")), + null, + ); + assertEquals(detectImageMime(new Uint8Array()), null); +}); + +Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => { + assertEquals(validateImageUpload(PNG), "image/png"); + assertThrows( + () => validateImageUpload(new TextEncoder().encode("")), + APIException, + "not a recognised image", + ); + const big = new Uint8Array(5 * 1024 * 1024 + 1); + big.set(PNG); + assertThrows(() => validateImageUpload(big), APIException, "too large"); +}); diff --git a/api/test/main.test.ts b/api/test/main.test.ts new file mode 100644 index 00000000..74b67cc3 --- /dev/null +++ b/api/test/main.test.ts @@ -0,0 +1,33 @@ +import { BASE_URL, get, request, test } from "./helpers.ts"; +import { assertEquals } from "@std/assert"; + +test("CORS: allowed origins get the headers, others don't", async () => { + const allowed = await get("/api/categories", { + headers: { Origin: "http://allowed.test" }, + }); + assertEquals( + allowed.headers.get("access-control-allow-origin"), + "http://allowed.test", + ); + // The public URL is always allowed. + const self = await get("/api/categories", { headers: { Origin: BASE_URL } }); + assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL); + const evil = await get("/api/categories", { + headers: { Origin: "http://evil.test" }, + }); + assertEquals(evil.status, 200); + assertEquals(evil.headers.get("access-control-allow-origin"), null); + + const preflight = await request("OPTIONS", "/api/dumps", { + headers: { + Origin: "http://allowed.test", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + }); + assertEquals(preflight.status, 204); + assertEquals( + preflight.headers.get("access-control-allow-origin"), + "http://allowed.test", + ); +}); diff --git a/api/test/middleware.test.ts b/api/test/middleware.test.ts deleted file mode 100644 index 768e4fdc..00000000 --- a/api/test/middleware.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -// The app-wide middleware: error rendering, CORS, OG tags for crawlers, and -// static file serving with its cache rules. -import { - BASE_URL, - createUser, - get, - insertDump, - insertPlaylist, - request, - test, - TEST_DIR, -} from "./helpers.ts"; -import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; -import { Application } from "@oak/oak"; -import { errorMiddleware } from "../middleware/error.ts"; -import { ogMiddleware } from "../middleware/og.ts"; -import { routeStaticFilesFrom } from "../lib/static.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; - -async function handle( - app: Application, - path: string, - headers: HeadersInit = {}, -) { - const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers })); - if (!res) throw new Error("no response"); - return { res, text: await res.text() }; -} - -Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => { - const app = new Application(); - app.use(errorMiddleware); - app.use((ctx) => { - if (ctx.request.url.pathname === "/api-error") { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope"); - } - throw new Error("secret internals"); - }); - const known = await handle(app, "/api-error"); - assertEquals(known.res.status, 404); - assertEquals(JSON.parse(known.text), { - success: false, - error: { code: "NOT_FOUND", message: "Nope" }, - }); - const unknown = await handle(app, "/boom"); - assertEquals(unknown.res.status, 500); - assertEquals(JSON.parse(unknown.text).error, { - code: "SERVER_ERROR", - message: "Unexpected server error", - }); -}); - -test("CORS: allowed origins get the headers, others don't", async () => { - const allowed = await get("/api/categories", { - headers: { Origin: "http://allowed.test" }, - }); - assertEquals( - allowed.headers.get("access-control-allow-origin"), - "http://allowed.test", - ); - // The public URL is always allowed. - const self = await get("/api/categories", { headers: { Origin: BASE_URL } }); - assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL); - const evil = await get("/api/categories", { - headers: { Origin: "http://evil.test" }, - }); - assertEquals(evil.status, 200); - assertEquals(evil.headers.get("access-control-allow-origin"), null); - - const preflight = await request("OPTIONS", "/api/dumps", { - headers: { - Origin: "http://allowed.test", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "authorization,content-type", - }, - }); - assertEquals(preflight.status, 204); - assertEquals( - preflight.headers.get("access-control-allow-origin"), - "http://allowed.test", - ); -}); - -test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => { - assertEquals( - (await get("/ws", { headers: { Origin: "http://evil.test" } })).status, - 403, - ); - assertEquals((await get("/ws")).status, 403); - assertEquals( - (await get("/ws", { headers: { Origin: "http://allowed.test" } })).status, - 426, - ); -}); - -function ogApp() { - const app = new Application(); - app.use(ogMiddleware); - app.use((ctx) => { - ctx.response.body = "fallthrough"; - }); - return app; -} - -test("ogMiddleware: dump pages get escaped OG tags", async () => { - const user = await createUser(); - const dump = insertDump({ - userId: user.id, - title: `Tom & "Jerry" `, - ); - assertStringIncludes( - text, - `Tom & "Jerry" <script>`, - ); - assertStringIncludes( - text, - ``, - ); - assertStringIncludes( - text, - ``, - ); - assertStringIncludes(text, `content="summary_large_image"`); - assertStringIncludes( - text, - ``, - ); - // The index's own title is replaced, not duplicated. - assertEquals(text.match(//g)?.length, 1); - assertFalse(text.includes("__SITE_NAME__")); -}); - -test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => { - const user = await createUser(); - const dump = insertDump({ userId: user.id, title: "Plain" }); - const { text } = await handle(ogApp(), `/dumps/${dump.id}`); - assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`); - assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`); - const file = insertDump({ - userId: user.id, - fileName: "a.png", - fileMime: "image/png", - }); - assertStringIncludes( - (await handle(ogApp(), `/dumps/${file.id}`)).text, - `content="${BASE_URL}/api/files/${file.id}"`, - ); -}); - -test("ogMiddleware: users and public playlists; private things fall through", async () => { - const user = await createUser(); - const app = ogApp(); - assertStringIncludes( - (await handle(app, `/users/${user.username}/~/votes`)).text, - `<meta property="og:title" content="${user.username}" />`, - ); - const pub = insertPlaylist({ userId: user.id, title: "Open list" }); - assertStringIncludes( - (await handle(app, `/playlists/${pub.slug}`)).text, - "Open list", - ); - const priv = insertPlaylist({ - userId: user.id, - title: "Closed", - isPublic: false, - }); - assertEquals( - (await handle(app, `/playlists/${priv.slug}`)).text, - "fallthrough", - ); - const secret = insertDump({ - userId: user.id, - title: "Secret", - isPrivate: true, - }); - assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough"); - assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough"); - assertEquals( - (await handle(app, `/api/dumps/${secret.id}`)).text, - "fallthrough", - ); - assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough"); -}); - -Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => { - const root = `${TEST_DIR}/static-${crypto.randomUUID()}`; - await Deno.mkdir(`${root}/assets`, { recursive: true }); - await Deno.writeTextFile( - `${root}/index.html`, - `<html><head><title>__SITE_NAME____BANDCAMP_PLAYER__`, - ); - const js = "console.log('app');".repeat(50); - await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js); - const br = new Uint8Array([1, 2, 3]); - await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br); - await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *"); - - const app = new Application(); - app.use(routeStaticFilesFrom([root])); - - const index = await handle(app, "/"); - assertEquals(index.res.headers.get("cache-control"), "no-cache"); - assertEquals( - index.text, - "gerbeurembed", - ); - - const spa = await handle(app, "/users/someone"); - assertEquals(spa.res.status, 200); - assertEquals(spa.text, index.text); - - // No Accept-Encoding header: the plain file, not the .br sibling. - const asset = await handle(app, "/assets/app-abc123.js"); - assertEquals(asset.text, js); - assertEquals(asset.res.headers.get("content-encoding"), null); - assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable"); - assertStringIncludes( - asset.res.headers.get("cache-control")!, - "max-age=31536000", - ); - assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding"); - - const compressed = await handle(app, "/assets/app-abc123.js", { - "Accept-Encoding": "br, gzip", - }); - assertEquals(compressed.res.headers.get("content-encoding"), "br"); - assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes - - const plain = await handle(app, "/robots.txt"); - assertEquals(plain.text, "User-agent: *"); - assertFalse( - (plain.res.headers.get("cache-control") ?? "").includes("immutable"), - ); - - // A chunk from an older build 404s rather than being answered with the index. - const stale = await handle(app, "/assets/app-old999.js"); - assertEquals(stale.res.status, 404); - assertFalse(stale.text.includes("")); - assertEquals( - (await handle(app, "/emojibase/17.0.0/missing.json")).res.status, - 404, - ); -}); diff --git a/api/test/middleware/auth.test.ts b/api/test/middleware/auth.test.ts new file mode 100644 index 00000000..82b738be --- /dev/null +++ b/api/test/middleware/auth.test.ts @@ -0,0 +1,18 @@ +import { createUser, get, test } from "../helpers.ts"; +import { assertEquals } from "@std/assert"; + +test("required auth: missing, malformed and forged tokens are 401", async () => { + assertEquals((await get("/api/users/me")).status, 401); + assertEquals( + (await get("/api/users/me", { headers: { Authorization: "Token x" } })) + .status, + 401, + ); + const media = (await createUser()).mediaToken; + assertEquals( + (await get("/api/users/me", { + headers: { Authorization: `Bearer ${media}` }, + })).status, + 401, + ); +}); diff --git a/api/test/middleware/error.test.ts b/api/test/middleware/error.test.ts new file mode 100644 index 00000000..57eb8309 --- /dev/null +++ b/api/test/middleware/error.test.ts @@ -0,0 +1,39 @@ +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( + app: Application, + path: string, + headers: HeadersInit = {}, +) { + const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers })); + if (!res) throw new Error("no response"); + return { res, text: await res.text() }; +} + +Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => { + const app = new Application(); + app.use(errorMiddleware); + app.use((ctx) => { + if (ctx.request.url.pathname === "/api-error") { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope"); + } + throw new Error("secret internals"); + }); + const known = await handle(app, "/api-error"); + assertEquals(known.res.status, 404); + assertEquals(JSON.parse(known.text), { + success: false, + error: { code: "NOT_FOUND", message: "Nope" }, + }); + const unknown = await handle(app, "/boom"); + assertEquals(unknown.res.status, 500); + assertEquals(JSON.parse(unknown.text).error, { + code: "SERVER_ERROR", + message: "Unexpected server error", + }); +}); diff --git a/api/test/middleware/og.test.ts b/api/test/middleware/og.test.ts new file mode 100644 index 00000000..843a9a89 --- /dev/null +++ b/api/test/middleware/og.test.ts @@ -0,0 +1,121 @@ +import { + BASE_URL, + createUser, + insertDump, + insertPlaylist, + test, +} from "../helpers.ts"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; +import { Application } from "@oak/oak"; +import { ogMiddleware } from "../../middleware/og.ts"; + +async function handle( + app: Application, + path: string, + headers: HeadersInit = {}, +) { + const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers })); + if (!res) throw new Error("no response"); + return { res, text: await res.text() }; +} + +function ogApp() { + const app = new Application(); + app.use(ogMiddleware); + app.use((ctx) => { + ctx.response.body = "fallthrough"; + }); + return app; +} + +test("ogMiddleware: dump pages get escaped OG tags", async () => { + const user = await createUser(); + const dump = insertDump({ + userId: user.id, + title: `Tom & "Jerry" `; -} - -const YT_DATA = { - metadata: { playlistMetadataRenderer: { title: "My mix" } }, - contents: { - list: [ - { - lockupViewModel: { - contentType: "LOCKUP_CONTENT_TYPE_VIDEO", - contentId: "vid1", - metadata: { - lockupMetadataViewModel: { title: { content: "First" } }, - }, - contentImage: { - overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], - }, - }, - }, - { - lockupViewModel: { - contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", - contentId: "ignored", - }, - }, - { - playlistVideoRenderer: { - videoId: "vid2", - title: { runs: [{ text: "Second" }] }, - lengthSeconds: "235", - }, - }, - ], - }, -}; - -Deno.test("parsePlaylistPage: current and legacy rows", () => { - assertEquals(parsePlaylistPage(ytPage(YT_DATA), "PLx"), { - listId: "PLx", - title: "My mix", - tracks: [ - { videoId: "vid1", title: "First", duration: 3723 }, - { videoId: "vid2", title: "Second", duration: 235 }, - ], - }); - assertEquals(parsePlaylistPage("", "PLx"), null); - assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null); -}); - -Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => { - using net = stubNetwork(() => html(tralbumPage(ALBUM))); - const url = "https://cached.bandcamp.com/album/x"; - const res = await get( - `/api/bandcamp/tracks?url=${encodeURIComponent(url)}`, - ); - assertEquals(res.status, 200); - assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); - assertEquals(res.data.tracks.length, 4); - // Same page (different query/case/trailing slash) → cache hit. - await get( - `/api/bandcamp/tracks?url=${ - encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y") - }`, - ); - assertEquals(net.calls.length, 1); - await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}&force=1`); - assertEquals(net.calls.length, 2); -}); - -Deno.test("GET /api/bandcamp/tracks: refusals and upstream failures", async () => { - using _ = stubNetwork((url) => { - if (url.hostname.startsWith("down")) { - return new Response("", { status: 503 }); - } - if (url.hostname.startsWith("json")) return Response.json({}); - return html("no tralbum"); - }); - const status = async (url: string) => - (await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`)).status; - assertEquals(await status("https://example.com/album/x"), 400); - assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400); - assertEquals(await status("http://localhost/album/x"), 400); - assertEquals(await status("https://down.bandcamp.com/album/x"), 502); - assertEquals(await status("https://json.bandcamp.com/album/x"), 502); - assertEquals(await status("https://empty.bandcamp.com/album/x"), 404); -}); - -Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => { - let cookie: string | null = null; - using net = stubNetwork((url, init) => { - cookie = new Headers(init?.headers).get("cookie"); - return url.searchParams.get("list") === "PLgone" - ? new Response("", { status: 404 }) - : html(ytPage(YT_DATA)); - }); - const ok = await get("/api/youtube/playlist?list=PLgood"); - assertEquals(ok.status, 200); - assertEquals(ok.data.tracks.length, 2); - assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); - // The pre-answered consent cookie keeps EU requests off the consent page. - assertEquals(cookie, "SOCS=CAI"); - await get("/api/youtube/playlist?list=PLgood"); // cached - assertEquals(net.calls.length, 1); - - assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400); - assertEquals((await get("/api/youtube/playlist")).status, 400); - assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502); - assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id")); -}); diff --git a/api/test/remote/bandcamp-tralbum.test.ts b/api/test/remote/bandcamp-tralbum.test.ts new file mode 100644 index 00000000..6a721ac9 --- /dev/null +++ b/api/test/remote/bandcamp-tralbum.test.ts @@ -0,0 +1,92 @@ +import "../setup.ts"; +import { assert, assertEquals } from "@std/assert"; +import { parseTralbum } from "../../remote/bandcamp-tralbum.ts"; + +function escapeAttr(s: string): string { + return s.replaceAll("&", "&").replaceAll('"', """); +} + +function tralbumPage(data: unknown): string { + return `
`; +} + +const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x"; + +const ALBUM = { + item_type: "album", + artist: "Artist", + art_id: 12345, + current: { title: 'The "Album" & more' }, + trackinfo: [ + { + track_num: 1, + title: "One", + duration: 180.5, + streaming: 1, + file: { "mp3-128": STREAM }, + }, + { + track_num: 2, + title: "Preorder", + streaming: 1, + unreleased_track: true, + file: null, + }, + { + track_num: 3, + title: "", + streaming: 1, + file: { "mp3-128": "https://evil.test/x.mp3" }, + }, + { + track_num: 4, + title: "Capped", + streaming: 1, + is_capped: true, + file: { "mp3-128": STREAM }, + }, + ], +}; + +Deno.test("parseTralbum: tracks, streamability and art", () => { + const album = parseTralbum( + tralbumPage(ALBUM), + "https://a.bandcamp.com/album/x", + ); + assert(album); + assertEquals(album.itemType, "album"); + assertEquals(album.artist, "Artist"); + // A title containing a literal & survives the single-pass decode. + assertEquals(album.title, 'The "Album" & more'); + assertEquals(album.artworkUrl, "https://f4.bcbits.com/img/a12345_16.jpg"); + assertEquals(album.tracks.map((t) => t.streamable), [ + true, + false, + false, + true, + ]); + assertEquals(album.tracks[0].streamUrl, STREAM); + assertEquals(album.tracks[0].duration, 180.5); + // A non-bcbits stream host is never relayed. + assertEquals(album.tracks[2].streamUrl, null); + assertEquals(album.tracks[2].title, "Track 3"); + assert(album.tracks[3].capped); +}); + +Deno.test("parseTralbum: pages it can't use", () => { + assertEquals(parseTralbum("", "u"), null); + assertEquals(parseTralbum(`
`, "u"), null); + assertEquals( + parseTralbum(tralbumPage({ ...ALBUM, hasAudio: false }), "u"), + null, + ); + assertEquals( + parseTralbum( + tralbumPage({ ...ALBUM, trackinfo: [ALBUM.trackinfo[1]] }), + "u", + ), + null, + ); +}); diff --git a/api/test/remote/fetch.test.ts b/api/test/remote/fetch.test.ts new file mode 100644 index 00000000..5dc15806 --- /dev/null +++ b/api/test/remote/fetch.test.ts @@ -0,0 +1,91 @@ +import { stubNetwork } from "../helpers.ts"; +import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; +import { + fetchWithTimeout, + isValidHttpUrl, + readTextCapped, +} from "../../remote/fetch.ts"; + +Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => { + assert(isValidHttpUrl("https://example.com/x")); + assert(isValidHttpUrl("http://gerbeur.test/dumps/abc")); + assertFalse(isValidHttpUrl("http://localhost:8000/")); + assertFalse(isValidHttpUrl("http://10.0.0.1/")); + assertFalse(isValidHttpUrl("file:///etc/passwd")); + assertFalse(isValidHttpUrl("nope")); +}); + +Deno.test("fetchWithTimeout: the deadline covers a body that never ends", async () => { + // Headers at once, then a trickle that never finishes: the read must fail + // at the deadline rather than hold the request open. + using _net = stubNetwork((_url, init) => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("")); + init?.signal?.addEventListener( + "abort", + () => controller.error(new DOMException("aborted", "AbortError")), + ); + }, + }), + { headers: { "content-type": "text/html" } }, + ) + ); + const res = await fetchWithTimeout("https://example.com/slow", 50); + await assertRejects(() => res.text()); +}); + +Deno.test("readTextCapped: stops reading at the cap", async () => { + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(new TextEncoder().encode("abcdefghij")); + }, + }); + const text = await readTextCapped(new Response(body), 25); + assertEquals(text, "abcdefghijabcdefghijabcde"); + assert(pulled < 10); + assertEquals(await readTextCapped(new Response("short")), "short"); +}); + +Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => { + using net = stubNetwork((url) => { + if (url.pathname === "/start") { + return new Response(null, { status: 302, headers: { location: "/end" } }); + } + if (url.pathname === "/to-private") { + return new Response(null, { + status: 301, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }); + } + if (url.pathname === "/loop") { + return new Response(null, { + status: 302, + headers: { location: "/loop" }, + }); + } + return new Response("done"); + }); + const res = await fetchWithTimeout("https://example.com/start"); + assertEquals(await res.text(), "done"); + assertEquals(net.calls.map((u) => u.pathname), ["/start", "/end"]); + + await assertRejects( + () => fetchWithTimeout("https://example.com/to-private"), + Error, + "private", + ); + await assertRejects( + () => fetchWithTimeout("https://example.com/loop"), + TypeError, + "Too many redirects", + ); + await assertRejects( + () => fetchWithTimeout("ftp://example.com/"), + TypeError, + "Unsupported protocol", + ); +}); diff --git a/api/test/remote/html.test.ts b/api/test/remote/html.test.ts new file mode 100644 index 00000000..c5f74561 --- /dev/null +++ b/api/test/remote/html.test.ts @@ -0,0 +1,163 @@ +import "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { + extractBestIcon, + extractFirstContentImage, + extractJsonLd, + extractLargeImage, + extractLinkHref, + extractMaskIconColor, + extractMetaName, + extractOgTag, + extractPageTitle, + extractThemeColor, + normalizeCssColor, +} from "../../remote/html.ts"; + +const BASE = "https://example.com/articles/1"; + +Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => { + const page = ` + + + + `; + assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤"); + assertEquals(extractOgTag(page, "description"), "Desc"); + assertEquals(extractOgTag(page, "image"), undefined); + assertEquals(extractMetaName(page, "twitter:title"), "TW"); +}); + +Deno.test("extractOgTag: content never spans into the next tag", () => { + const page = ``; + assertEquals(extractOgTag(page, "title"), undefined); +}); + +Deno.test("extractPageTitle", () => { + assertEquals( + extractPageTitle(" A <b> "), + "A ", + ); + assertEquals(extractPageTitle("

none

"), undefined); +}); + +Deno.test("extractThemeColor: unscoped, then light, then any", () => { + assertEquals( + extractThemeColor(` + + `), + "#fff", + ); + assertEquals( + extractThemeColor(` + + `), + "#123", + ); + assertEquals( + extractThemeColor( + ``, + ), + "#000", + ); + assertEquals( + extractMaskIconColor( + ``, + ), + "#5bbad5", + ); +}); + +Deno.test("normalizeCssColor", () => { + assertEquals(normalizeCssColor("#ABC"), "#aabbcc"); + assertEquals(normalizeCssColor("#abcd"), "#aabbcc"); + assertEquals(normalizeCssColor("#112233"), "#112233"); + assertEquals(normalizeCssColor("#11223380"), "#112233"); + assertEquals(normalizeCssColor(" Navy "), "#000080"); + assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080"); + assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080"); + assertEquals(normalizeCssColor("#12345"), undefined); + assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined); + assertEquals(normalizeCssColor("rgb(1,2)"), undefined); + assertEquals(normalizeCssColor(undefined), undefined); +}); + +Deno.test("extractJsonLd: @graph, arrays and image shapes", () => { + const ld = (data: unknown) => + ``; + assertEquals( + extractJsonLd( + ld({ + "@graph": [{ "@type": "WebSite" }, { + headline: "Head", + image: { url: "https://i/1.jpg" }, + }], + }), + ), + { title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" }, + ); + assertEquals( + extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl, + "https://i/2.jpg", + ); + assertEquals( + extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl, + undefined, + ); + // Invalid JSON is skipped for the next block. + assertEquals( + extractJsonLd( + `${ + ld({ name: "Second" }) + }`, + ).title, + "Second", + ); + assertEquals(extractJsonLd("

"), {}); +}); + +Deno.test("image extraction: large images, content images, relative URLs", () => { + const page = ` + + + + + `; + assertEquals( + extractLargeImage(page, BASE), + "https://example.com/photos/cat.jpg", + ); + assertEquals( + extractFirstContentImage(page, BASE), + "https://example.com/small.jpg", + ); + assertEquals( + extractFirstContentImage(``, BASE), + "https://cdn.test/a.jpg", + ); +}); + +Deno.test("extractBestIcon: SVG beats everything, else the largest", () => { + assertEquals( + extractBestIcon( + ` + + `, + BASE, + ), + "https://example.com/180.png", + ); + assertEquals( + extractBestIcon( + ` + `, + BASE, + ), + "https://example.com/icon", + ); + assertEquals(extractBestIcon("

", BASE), undefined); + assertEquals( + extractLinkHref(``, "alternate", BASE), + "https://example.com/feed", + ); +}); diff --git a/api/test/remote/providers/bandcamp.test.ts b/api/test/remote/providers/bandcamp.test.ts new file mode 100644 index 00000000..ec2414c6 --- /dev/null +++ b/api/test/remote/providers/bandcamp.test.ts @@ -0,0 +1,14 @@ +import { html, stubNetwork } from "../../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { fetchRichContent } from "../../../services/rich-content-service.ts"; + +Deno.test("bandcamp provider", async () => { + using _ = stubNetwork(() => + html(` + `) + ); + const bc = await fetchRichContent("https://artist.bandcamp.com/album/a"); + assertEquals(bc?.type, "bandcamp"); + assertEquals(bc?.title, "Album"); + assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1"); +}); diff --git a/api/test/remote/providers/generic.test.ts b/api/test/remote/providers/generic.test.ts new file mode 100644 index 00000000..4412e0a6 --- /dev/null +++ b/api/test/remote/providers/generic.test.ts @@ -0,0 +1,53 @@ +import { html, stubNetwork } from "../../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { fetchRichContent } from "../../../services/rich-content-service.ts"; + +const BASE = "https://example.com/articles/1"; + +Deno.test("generic provider: the full fallback chain", async () => { + using _ = stubNetwork(() => + html(` + Page title + + + + + + + `) + ); + const content = await fetchRichContent(BASE); + assertEquals(content, { + type: "generic", + url: BASE, + title: "OG title", + description: "Plain description", + thumbnailUrl: "https://example.com/og.jpg", + faviconUrl: "https://example.com/favicon-32.png", + accentColor: "#0080ff", + siteName: "Example", + }); +}); + +Deno.test("generic provider: a site-wide og:url means og:title/image are ignored", async () => { + using _ = stubNetwork(() => + html(` + + + + + `) + ); + const content = await fetchRichContent("https://www.example.com/articles/1"); + assertEquals(content?.title, "Article title"); + assertEquals(content?.thumbnailUrl, "https://www.example.com/photo.jpg"); + assertEquals(content?.siteName, "example.com"); + assertEquals(content?.faviconUrl, "https://www.example.com/favicon.ico"); +}); + +Deno.test("generic provider: non-HTML responses carry just the URL", async () => { + using _ = stubNetwork(() => + new Response("{}", { headers: { "content-type": "application/json" } }) + ); + assertEquals(await fetchRichContent(BASE), { type: "generic", url: BASE }); +}); diff --git a/api/test/remote/providers/self.test.ts b/api/test/remote/providers/self.test.ts new file mode 100644 index 00000000..f7419dc6 --- /dev/null +++ b/api/test/remote/providers/self.test.ts @@ -0,0 +1,28 @@ +import { createUser, insertDump, stubNetwork, test } from "../../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { fetchRichContent } from "../../../services/rich-content-service.ts"; + +test("self provider: our own dump, user and playlist pages come from the DB", async () => { + const user = await createUser(); + const dump = insertDump({ + userId: user.id, + title: "Local dump", + comment: "About it", + richContent: { type: "generic", url: "x", thumbnailUrl: "https://t/x.jpg" }, + }); + using net = stubNetwork(() => { + throw new Error("no network for self links"); + }); + const content = await fetchRichContent( + `http://gerbeur.test/dumps/${dump.slug}`, + ); + assertEquals(content?.title, "Local dump"); + assertEquals(content?.description, "About it"); + assertEquals(content?.thumbnailUrl, "https://t/x.jpg"); + assertEquals(content?.siteName, "gerbeur"); + const profile = await fetchRichContent( + `http://gerbeur.test/users/${user.username}`, + ); + assertEquals(profile?.title, user.username); + assertEquals(net.calls.length, 0); +}); diff --git a/api/test/remote/providers/soundcloud.test.ts b/api/test/remote/providers/soundcloud.test.ts new file mode 100644 index 00000000..0b3575d6 --- /dev/null +++ b/api/test/remote/providers/soundcloud.test.ts @@ -0,0 +1,16 @@ +import { html, stubNetwork } from "../../helpers.ts"; +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { fetchRichContent } from "../../../services/rich-content-service.ts"; + +Deno.test("soundcloud provider", async () => { + using _ = stubNetwork(() => + html(` + `) + ); + const sc = await fetchRichContent("https://soundcloud.com/artist/track"); + assertEquals(sc?.type, "soundcloud"); + assertStringIncludes( + sc?.embedUrl ?? "", + encodeURIComponent("https://soundcloud.com/artist/track"), + ); +}); diff --git a/api/test/remote/providers/youtube.test.ts b/api/test/remote/providers/youtube.test.ts new file mode 100644 index 00000000..dcaf203b --- /dev/null +++ b/api/test/remote/providers/youtube.test.ts @@ -0,0 +1,57 @@ +import { html, stubNetwork } from "../../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { fetchRichContent } from "../../../services/rich-content-service.ts"; + +Deno.test("youtube provider: video with start time and playlist", async () => { + using net = stubNetwork((url) => + url.pathname === "/oembed" + ? Response.json({ title: "Never Gonna", thumbnail_url: "ignored" }) + : new Response(null, { status: 404 }) + ); + const content = await fetchRichContent( + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s", + ); + assertEquals(content?.type, "youtube"); + assertEquals(content?.videoId, "dQw4w9WgXcQ"); + assertEquals(content?.title, "Never Gonna"); + assertEquals( + content?.thumbnailUrl, + "https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + ); + assertEquals( + content?.embedUrl, + "https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&list=PL123&start=90", + ); + assertEquals(net.calls[0].hostname, "www.youtube.com"); +}); + +Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () => { + using _ = stubNetwork((url) => + url.pathname === "/oembed" + ? Response.json({ + title: "T", + thumbnail_url: "https://i.ytimg.com/x.jpg", + }) + : html(` + `) + ); + assertEquals( + (await fetchRichContent("https://youtu.be/abc123?t=42"))?.embedUrl, + "https://www.youtube.com/embed/abc123?rel=0&start=42", + ); + assertEquals( + (await fetchRichContent("https://youtube.com/shorts/xyz"))?.videoId, + "xyz", + ); + const list = await fetchRichContent( + "https://www.youtube.com/playlist?list=PLabc", + ); + assertEquals( + list?.embedUrl, + "https://www.youtube.com/embed/videoseries?list=PLabc&rel=0", + ); + assertEquals(list?.thumbnailUrl, "https://i.ytimg.com/x.jpg"); + const channel = await fetchRichContent("https://www.youtube.com/@someone"); + assertEquals(channel?.title, "Channel"); + assertEquals(channel?.embedUrl, undefined); +}); diff --git a/api/test/public-address.test.ts b/api/test/remote/public-address.test.ts similarity index 97% rename from api/test/public-address.test.ts rename to api/test/remote/public-address.test.ts index 1ce69e35..f755ea63 100644 --- a/api/test/public-address.test.ts +++ b/api/test/remote/public-address.test.ts @@ -1,11 +1,11 @@ -import "./setup.ts"; +import "../setup.ts"; +import { stubNetwork } from "../helpers.ts"; import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; import { isPrivateAddress, isPrivateHostname, resolvePublicAddress, -} from "../lib/public-address.ts"; -import { stubNetwork } from "./helpers.ts"; +} from "../../remote/public-address.ts"; Deno.test("isPrivateAddress: private and reserved IPv4 ranges", () => { for ( diff --git a/api/test/remote/youtube-playlist.test.ts b/api/test/remote/youtube-playlist.test.ts new file mode 100644 index 00000000..78194180 --- /dev/null +++ b/api/test/remote/youtube-playlist.test.ts @@ -0,0 +1,53 @@ +import "../setup.ts"; +import { assertEquals } from "@std/assert"; +import { parsePlaylistPage } from "../../remote/youtube-playlist.ts"; + +function ytPage(data: unknown): string { + return ``; +} + +const YT_DATA = { + metadata: { playlistMetadataRenderer: { title: "My mix" } }, + contents: { + list: [ + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_VIDEO", + contentId: "vid1", + metadata: { + lockupMetadataViewModel: { title: { content: "First" } }, + }, + contentImage: { + overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], + }, + }, + }, + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", + contentId: "ignored", + }, + }, + { + playlistVideoRenderer: { + videoId: "vid2", + title: { runs: [{ text: "Second" }] }, + lengthSeconds: "235", + }, + }, + ], + }, +}; + +Deno.test("parsePlaylistPage: current and legacy rows", () => { + assertEquals(parsePlaylistPage(ytPage(YT_DATA), "PLx"), { + listId: "PLx", + title: "My mix", + tracks: [ + { videoId: "vid1", title: "First", duration: 3723 }, + { videoId: "vid2", title: "Second", duration: 235 }, + ], + }); + assertEquals(parsePlaylistPage("", "PLx"), null); + assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null); +}); diff --git a/api/test/rich-content.test.ts b/api/test/rich-content.test.ts deleted file mode 100644 index 7493d0cc..00000000 --- a/api/test/rich-content.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -// Link previews: the HTML extractors, the providers, and fetchWithTimeout's -// guard on every hop. All network access is stubbed. -import { - createUser, - get, - html, - insertDump, - PNG, - stubNetwork, - test, -} from "./helpers.ts"; -import { - assert, - assertEquals, - assertFalse, - assertRejects, - assertStringIncludes, -} from "@std/assert"; -import { - extractBestIcon, - extractFirstContentImage, - extractJsonLd, - extractLargeImage, - extractLinkHref, - extractMaskIconColor, - extractMetaName, - extractOgTag, - extractPageTitle, - extractThemeColor, - fetchRichContent, - fetchWithTimeout, - isValidHttpUrl, - normalizeCssColor, - readTextCapped, - tryFetchRichContent, -} from "../services/rich-content-service.ts"; - -const BASE = "https://example.com/articles/1"; - -Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => { - const page = ` - - - - `; - assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤"); - assertEquals(extractOgTag(page, "description"), "Desc"); - assertEquals(extractOgTag(page, "image"), undefined); - assertEquals(extractMetaName(page, "twitter:title"), "TW"); -}); - -Deno.test("extractOgTag: content never spans into the next tag", () => { - const page = ``; - assertEquals(extractOgTag(page, "title"), undefined); -}); - -Deno.test("extractPageTitle", () => { - assertEquals( - extractPageTitle(" A <b> "), - "A ", - ); - assertEquals(extractPageTitle("

none

"), undefined); -}); - -Deno.test("extractThemeColor: unscoped, then light, then any", () => { - assertEquals( - extractThemeColor(` - - `), - "#fff", - ); - assertEquals( - extractThemeColor(` - - `), - "#123", - ); - assertEquals( - extractThemeColor( - ``, - ), - "#000", - ); - assertEquals( - extractMaskIconColor( - ``, - ), - "#5bbad5", - ); -}); - -Deno.test("normalizeCssColor", () => { - assertEquals(normalizeCssColor("#ABC"), "#aabbcc"); - assertEquals(normalizeCssColor("#abcd"), "#aabbcc"); - assertEquals(normalizeCssColor("#112233"), "#112233"); - assertEquals(normalizeCssColor("#11223380"), "#112233"); - assertEquals(normalizeCssColor(" Navy "), "#000080"); - assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080"); - assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080"); - assertEquals(normalizeCssColor("#12345"), undefined); - assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined); - assertEquals(normalizeCssColor("rgb(1,2)"), undefined); - assertEquals(normalizeCssColor(undefined), undefined); -}); - -Deno.test("extractJsonLd: @graph, arrays and image shapes", () => { - const ld = (data: unknown) => - ``; - assertEquals( - extractJsonLd( - ld({ - "@graph": [{ "@type": "WebSite" }, { - headline: "Head", - image: { url: "https://i/1.jpg" }, - }], - }), - ), - { title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" }, - ); - assertEquals( - extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl, - "https://i/2.jpg", - ); - assertEquals( - extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl, - undefined, - ); - // Invalid JSON is skipped for the next block. - assertEquals( - extractJsonLd( - `${ - ld({ name: "Second" }) - }`, - ).title, - "Second", - ); - assertEquals(extractJsonLd("

"), {}); -}); - -Deno.test("image extraction: large images, content images, relative URLs", () => { - const page = ` - - - - - `; - assertEquals( - extractLargeImage(page, BASE), - "https://example.com/photos/cat.jpg", - ); - assertEquals( - extractFirstContentImage(page, BASE), - "https://example.com/small.jpg", - ); - assertEquals( - extractFirstContentImage(``, BASE), - "https://cdn.test/a.jpg", - ); -}); - -Deno.test("extractBestIcon: SVG beats everything, else the largest", () => { - assertEquals( - extractBestIcon( - ` - - `, - BASE, - ), - "https://example.com/180.png", - ); - assertEquals( - extractBestIcon( - ` - `, - BASE, - ), - "https://example.com/icon", - ); - assertEquals(extractBestIcon("

", BASE), undefined); - assertEquals( - extractLinkHref(``, "alternate", BASE), - "https://example.com/feed", - ); -}); - -Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => { - assert(isValidHttpUrl("https://example.com/x")); - assert(isValidHttpUrl("http://gerbeur.test/dumps/abc")); - assertFalse(isValidHttpUrl("http://localhost:8000/")); - assertFalse(isValidHttpUrl("http://10.0.0.1/")); - assertFalse(isValidHttpUrl("file:///etc/passwd")); - assertFalse(isValidHttpUrl("nope")); -}); - -Deno.test("fetchWithTimeout: the deadline covers a body that never ends", async () => { - // Headers at once, then a trickle that never finishes: the read must fail - // at the deadline rather than hold the request open. - using _net = stubNetwork((_url, init) => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("")); - init?.signal?.addEventListener( - "abort", - () => controller.error(new DOMException("aborted", "AbortError")), - ); - }, - }), - { headers: { "content-type": "text/html" } }, - ) - ); - const res = await fetchWithTimeout("https://example.com/slow", 50); - await assertRejects(() => res.text()); -}); - -Deno.test("tryFetchRichContent: a preview and the post that follows share one fetch", async () => { - using net = stubNetwork(() => html(`Once`)); - const first = await tryFetchRichContent("https://example.com/once"); - const second = await tryFetchRichContent("https://example.com/once"); - assertEquals(second, first); - assertEquals(net.calls.length, 1); - // A refresh always goes to the page. - await tryFetchRichContent("https://example.com/once", { fresh: true }); - assertEquals(net.calls.length, 2); -}); - -Deno.test("tryFetchRichContent: failures aren't kept", async () => { - let up = false; - using net = stubNetwork(() => { - if (!up) throw new TypeError("connection refused"); - return html(`Up`); - }); - const url = "https://example.com/flaky"; - assertFalse((await tryFetchRichContent(url)).ok); - up = true; - const { ok, content } = await tryFetchRichContent(url); - assert(ok); - assertEquals(content?.title, "Up"); - assertEquals(net.calls.length, 2); -}); - -Deno.test("readTextCapped: stops reading at the cap", async () => { - let pulled = 0; - const body = new ReadableStream({ - pull(controller) { - pulled++; - controller.enqueue(new TextEncoder().encode("abcdefghij")); - }, - }); - const text = await readTextCapped(new Response(body), 25); - assertEquals(text, "abcdefghijabcdefghijabcde"); - assert(pulled < 10); - assertEquals(await readTextCapped(new Response("short")), "short"); -}); - -Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => { - using net = stubNetwork((url) => { - if (url.pathname === "/start") { - return new Response(null, { status: 302, headers: { location: "/end" } }); - } - if (url.pathname === "/to-private") { - return new Response(null, { - status: 301, - headers: { location: "http://169.254.169.254/latest/meta-data" }, - }); - } - if (url.pathname === "/loop") { - return new Response(null, { - status: 302, - headers: { location: "/loop" }, - }); - } - return new Response("done"); - }); - const res = await fetchWithTimeout("https://example.com/start"); - assertEquals(await res.text(), "done"); - assertEquals(net.calls.map((u) => u.pathname), ["/start", "/end"]); - - await assertRejects( - () => fetchWithTimeout("https://example.com/to-private"), - Error, - "private", - ); - await assertRejects( - () => fetchWithTimeout("https://example.com/loop"), - TypeError, - "Too many redirects", - ); - await assertRejects( - () => fetchWithTimeout("ftp://example.com/"), - TypeError, - "Unsupported protocol", - ); -}); - -Deno.test("generic provider: the full fallback chain", async () => { - using _ = stubNetwork(() => - html(` - Page title - - - - - - - `) - ); - const content = await fetchRichContent(BASE); - assertEquals(content, { - type: "generic", - url: BASE, - title: "OG title", - description: "Plain description", - thumbnailUrl: "https://example.com/og.jpg", - faviconUrl: "https://example.com/favicon-32.png", - accentColor: "#0080ff", - siteName: "Example", - }); -}); - -Deno.test("generic provider: a site-wide og:url means og:title/image are ignored", async () => { - using _ = stubNetwork(() => - html(` - - - - - `) - ); - const content = await fetchRichContent("https://www.example.com/articles/1"); - assertEquals(content?.title, "Article title"); - assertEquals(content?.thumbnailUrl, "https://www.example.com/photo.jpg"); - assertEquals(content?.siteName, "example.com"); - assertEquals(content?.faviconUrl, "https://www.example.com/favicon.ico"); -}); - -Deno.test("generic provider: non-HTML responses carry just the URL", async () => { - using _ = stubNetwork(() => - new Response("{}", { headers: { "content-type": "application/json" } }) - ); - assertEquals(await fetchRichContent(BASE), { type: "generic", url: BASE }); -}); - -Deno.test("tryFetchRichContent: an unreachable page is ok:false with a stub", async () => { - using _ = stubNetwork(() => { - throw new TypeError("connection refused"); - }); - assertEquals(await tryFetchRichContent("https://www.example.com/x"), { - ok: false, - content: { - type: "generic", - url: "https://www.example.com/x", - siteName: "example.com", - }, - }); -}); - -Deno.test("youtube provider: video with start time and playlist", async () => { - using net = stubNetwork((url) => - url.pathname === "/oembed" - ? Response.json({ title: "Never Gonna", thumbnail_url: "ignored" }) - : new Response(null, { status: 404 }) - ); - const content = await fetchRichContent( - "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s", - ); - assertEquals(content?.type, "youtube"); - assertEquals(content?.videoId, "dQw4w9WgXcQ"); - assertEquals(content?.title, "Never Gonna"); - assertEquals( - content?.thumbnailUrl, - "https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg", - ); - assertEquals( - content?.embedUrl, - "https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&list=PL123&start=90", - ); - assertEquals(net.calls[0].hostname, "www.youtube.com"); -}); - -Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () => { - using _ = stubNetwork((url) => - url.pathname === "/oembed" - ? Response.json({ - title: "T", - thumbnail_url: "https://i.ytimg.com/x.jpg", - }) - : html(` - `) - ); - assertEquals( - (await fetchRichContent("https://youtu.be/abc123?t=42"))?.embedUrl, - "https://www.youtube.com/embed/abc123?rel=0&start=42", - ); - assertEquals( - (await fetchRichContent("https://youtube.com/shorts/xyz"))?.videoId, - "xyz", - ); - const list = await fetchRichContent( - "https://www.youtube.com/playlist?list=PLabc", - ); - assertEquals( - list?.embedUrl, - "https://www.youtube.com/embed/videoseries?list=PLabc&rel=0", - ); - assertEquals(list?.thumbnailUrl, "https://i.ytimg.com/x.jpg"); - const channel = await fetchRichContent("https://www.youtube.com/@someone"); - assertEquals(channel?.title, "Channel"); - assertEquals(channel?.embedUrl, undefined); -}); - -Deno.test("bandcamp and soundcloud providers", async () => { - using _ = stubNetwork(() => - html(` - `) - ); - const bc = await fetchRichContent("https://artist.bandcamp.com/album/a"); - assertEquals(bc?.type, "bandcamp"); - assertEquals(bc?.title, "Album"); - assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1"); - const sc = await fetchRichContent("https://soundcloud.com/artist/track"); - assertEquals(sc?.type, "soundcloud"); - assertStringIncludes( - sc?.embedUrl ?? "", - encodeURIComponent("https://soundcloud.com/artist/track"), - ); -}); - -test("self provider: our own dump, user and playlist pages come from the DB", async () => { - const user = await createUser(); - const dump = insertDump({ - userId: user.id, - title: "Local dump", - comment: "About it", - richContent: { type: "generic", url: "x", thumbnailUrl: "https://t/x.jpg" }, - }); - using net = stubNetwork(() => { - throw new Error("no network for self links"); - }); - const content = await fetchRichContent( - `http://gerbeur.test/dumps/${dump.slug}`, - ); - assertEquals(content?.title, "Local dump"); - assertEquals(content?.description, "About it"); - assertEquals(content?.thumbnailUrl, "https://t/x.jpg"); - assertEquals(content?.siteName, "gerbeur"); - const profile = await fetchRichContent( - `http://gerbeur.test/users/${user.username}`, - ); - assertEquals(profile?.title, user.username); - assertEquals(net.calls.length, 0); -}); - -Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async () => { - using net = stubNetwork((url) => - url.pathname.endsWith(".svg") - ? new Response("", { headers: { "content-type": "image/svg+xml" } }) - : new Response(PNG, { headers: { "content-type": "image/png" } }) - ); - const path = `/api/proxy-image?url=${ - encodeURIComponent("https://cdn.example.com/a.png") - }`; - const first = await get(path); - assertEquals(first.status, 200); - assertEquals(first.headers.get("content-type"), "image/png"); - assertEquals(first.bytes, PNG); - const second = await get(path); - assertEquals(second.bytes, PNG); - assertEquals(net.calls.length, 1); - - const svg = await get( - `/api/proxy-image?url=${ - encodeURIComponent("https://cdn.example.com/a.svg") - }`, - ); - assertEquals(svg.status, 400); -}); diff --git a/api/test/routes/attachments.test.ts b/api/test/routes/attachments.test.ts new file mode 100644 index 00000000..c00f61fb --- /dev/null +++ b/api/test/routes/attachments.test.ts @@ -0,0 +1,90 @@ +import { + count, + createUser, + db, + formWithFile, + get, + insertDump, + PNG, + post, + test, +} from "../helpers.ts"; +import type { AttachmentResponse } from "../../../shared/dump.ts"; +import type { Comment } from "../../../shared/comment.ts"; +import { assertEquals } from "@std/assert"; + +test("attachments: upload an image, serve it back", async () => { + const user = await createUser(); + const res = await post("/api/attachments", { + auth: user.auth, + form: formWithFile(PNG), + }); + assertEquals(res.status, 201); + const id = res.data.id; + const served = await get(`/api/attachments/${id}`); + assertEquals(served.status, 200); + assertEquals(served.bytes, PNG); + assertEquals(served.headers.get("content-type"), "image/png"); + assertEquals(served.headers.get("x-content-type-options"), "nosniff"); + assertEquals(served.headers.get("cache-control"), "public, max-age=3600"); + assertEquals( + (await get(`/api/attachments/${crypto.randomUUID()}`)).status, + 404, + ); +}); + +test("attachments: refuses non-images, non-multipart and anonymous uploads", async () => { + const user = await createUser(); + assertEquals( + (await post("/api/attachments", { + auth: user.auth, + form: formWithFile( + new TextEncoder().encode("`; +} + +const YT_DATA = { + metadata: { playlistMetadataRenderer: { title: "My mix" } }, + contents: { + list: [ + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_VIDEO", + contentId: "vid1", + metadata: { + lockupMetadataViewModel: { title: { content: "First" } }, + }, + contentImage: { + overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], + }, + }, + }, + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", + contentId: "ignored", + }, + }, + { + playlistVideoRenderer: { + videoId: "vid2", + title: { runs: [{ text: "Second" }] }, + lengthSeconds: "235", + }, + }, + ], + }, +}; + +Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => { + let cookie: string | null = null; + using net = stubNetwork((url, init) => { + cookie = new Headers(init?.headers).get("cookie"); + return url.searchParams.get("list") === "PLgone" + ? new Response("", { status: 404 }) + : html(ytPage(YT_DATA)); + }); + const ok = await get("/api/youtube/playlist?list=PLgood"); + assertEquals(ok.status, 200); + assertEquals(ok.data.tracks.length, 2); + assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); + // The pre-answered consent cookie keeps EU requests off the consent page. + assertEquals(cookie, "SOCS=CAI"); + await get("/api/youtube/playlist?list=PLgood"); // cached + assertEquals(net.calls.length, 1); + + assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400); + assertEquals((await get("/api/youtube/playlist")).status, 400); + assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502); + assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id")); +}); diff --git a/api/test/chat.test.ts b/api/test/services/chat-service.test.ts similarity index 84% rename from api/test/chat.test.ts rename to api/test/services/chat-service.test.ts index 6187f648..a2fa23d7 100644 --- a/api/test/chat.test.ts +++ b/api/test/services/chat-service.test.ts @@ -1,6 +1,4 @@ -// Chat history, cursors and read markers. The live side (send, edit, delete -// over the socket) is covered in ws.test.ts. -import { createUser, db, get, test } from "./helpers.ts"; +import { createUser, db, test } from "../helpers.ts"; import { assertEquals, assertThrows } from "@std/assert"; import { createChatMessage, @@ -10,8 +8,8 @@ import { getRecentChatMessages, markChatRead, updateChatMessage, -} from "../services/chat-service.ts"; -import { APIException, type ChatMessage } from "../model/interfaces.ts"; +} from "../../services/chat-service.ts"; +import { APIException } from "../../lib/api-exception.ts"; function insertMessage( userId: string, @@ -125,24 +123,6 @@ test("history pages: newest page in ascending order, older pages by cursor", asy assertEquals(getRecentChatMessages(-5).length, 1); }); -test("GET /api/chat: members only, with limit and both cursors", async () => { - const user = await createUser(); - const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n))); - assertEquals((await get("/api/chat")).status, 401); - const bodies = async (q: string) => - (await get(`/api/chat${q}`, { auth: user.auth })).data.map( - (m) => m.body, - ); - assertEquals(await bodies(""), ["m1", "m2", "m3"]); - assertEquals(await bodies("?limit=1"), ["m3"]); - assertEquals(await bodies("?limit=abc"), ["m1", "m2", "m3"]); - assertEquals(await bodies(`?before=${T(3)}&beforeId=${ids[2]}`), [ - "m1", - "m2", - ]); - assertEquals(await bodies(`?after=${T(1)}&afterId=${ids[0]}`), ["m2", "m3"]); -}); - test("read state: unread counts others' messages since the marker", async () => { const me = await createUser({ createdAt: T(0) }); const other = await createUser(); diff --git a/api/test/services/notification-service.test.ts b/api/test/services/notification-service.test.ts new file mode 100644 index 00000000..2bbfd6b0 --- /dev/null +++ b/api/test/services/notification-service.test.ts @@ -0,0 +1,48 @@ +import { + count, + createUser, + db, + insertDump, + patch, + post, + test, +} from "../helpers.ts"; +import type { Comment } from "../../../shared/comment.ts"; +import { assertEquals } from "@std/assert"; + +test("mentions: notify the named, once per context, never the author or an email domain", async () => { + const author = await createUser(); + const named = await createUser(); + const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" }); + const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + + `me @${author.username}, mail x@${named.username}.com, ` + + `[@${named.username}](link) @nobody_known`; + const c = await post(`/api/dumps/${dumpId}/comments`, { + auth: author.auth, + json: { body }, + }); + const notes = db.prepare( + `SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`, + ).all() as { user_id: string; data: string }[]; + assertEquals(notes.length, 1); + assertEquals(notes[0].user_id, named.id); + assertEquals(JSON.parse(notes[0].data), { + mentionerId: author.id, + mentionerUsername: author.username, + contextType: "comment", + contextId: c.data.id, + contextTitle: "Topic", + dumpId, + }); + // Editing the comment doesn't notify a second time. + await patch(`/api/comments/${c.data.id}`, { + auth: author.auth, + json: { body: `again @${named.username}` }, + }); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`, + ), + 1, + ); +}); diff --git a/api/test/services/rich-content-service.test.ts b/api/test/services/rich-content-service.test.ts new file mode 100644 index 00000000..1a44b10f --- /dev/null +++ b/api/test/services/rich-content-service.test.ts @@ -0,0 +1,43 @@ +import { html, stubNetwork } from "../helpers.ts"; +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { tryFetchRichContent } from "../../services/rich-content-service.ts"; + +Deno.test("tryFetchRichContent: a preview and the post that follows share one fetch", async () => { + using net = stubNetwork(() => html(`Once`)); + const first = await tryFetchRichContent("https://example.com/once"); + const second = await tryFetchRichContent("https://example.com/once"); + assertEquals(second, first); + assertEquals(net.calls.length, 1); + // A refresh always goes to the page. + await tryFetchRichContent("https://example.com/once", { fresh: true }); + assertEquals(net.calls.length, 2); +}); + +Deno.test("tryFetchRichContent: failures aren't kept", async () => { + let up = false; + using net = stubNetwork(() => { + if (!up) throw new TypeError("connection refused"); + return html(`Up`); + }); + const url = "https://example.com/flaky"; + assertFalse((await tryFetchRichContent(url)).ok); + up = true; + const { ok, content } = await tryFetchRichContent(url); + assert(ok); + assertEquals(content?.title, "Up"); + assertEquals(net.calls.length, 2); +}); + +Deno.test("tryFetchRichContent: an unreachable page is ok:false with a stub", async () => { + using _ = stubNetwork(() => { + throw new TypeError("connection refused"); + }); + assertEquals(await tryFetchRichContent("https://www.example.com/x"), { + ok: false, + content: { + type: "generic", + url: "https://www.example.com/x", + siteName: "example.com", + }, + }); +}); diff --git a/api/test/setup.ts b/api/test/setup.ts index 5daac0b3..8c685f95 100644 --- a/api/test/setup.ts +++ b/api/test/setup.ts @@ -1,5 +1,5 @@ // Points the app at a throwaway database and uploads directory before any app -// module is evaluated. api/config.ts reads the environment and api/model/db.ts +// 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 diff --git a/api/test/uploads.test.ts b/api/test/uploads.test.ts deleted file mode 100644 index bdd3c8d3..00000000 --- a/api/test/uploads.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -// Serving uploaded bytes: dump files (headers, ranges, privacy), video -// thumbnails, attachments, avatars. -import { - count, - createUser, - db, - formWithFile, - get, - insertDump, - PNG, - post, - test, - TEST_DIR, -} from "./helpers.ts"; -import type { - AttachmentResponse, - Comment, - SelfUser, -} from "../model/interfaces.ts"; -import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; - -const BYTES = new TextEncoder().encode("0123456789"); - -test("GET /api/files/:id: headers for a safe inline type", async () => { - const user = await createUser(); - const { id } = insertDump({ - userId: user.id, - fileName: "notes.txt", - fileMime: "text/plain", - fileData: BYTES, - }); - const res = await get(`/api/files/${id}`); - assertEquals(res.status, 200); - assertEquals(res.bytes, BYTES); - assertEquals(res.headers.get("content-type"), "text/plain"); - assertEquals(res.headers.get("content-length"), "10"); - assertEquals(res.headers.get("accept-ranges"), "bytes"); - assertEquals(res.headers.get("x-content-type-options"), "nosniff"); - assertEquals(res.headers.get("referrer-policy"), "no-referrer"); - assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox"); - assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); - // No version in the URL: kept, but asked about again each time. - assertEquals(res.headers.get("cache-control"), "public, no-cache"); - const etag = res.headers.get("etag")!; - assertStringIncludes(etag, 'W/"'); - - // The client already has it: a 304, no bytes. - const again = await get(`/api/files/${id}`, { - headers: { "If-None-Match": etag }, - }); - assertEquals(again.status, 304); - assertEquals(again.bytes.byteLength, 0); - - // The app's URL names the file's current version, which never changes. - const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`; - assertEquals( - (await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"), - "public, max-age=31536000, immutable", - ); - // A stale version is revalidated like no version at all. - assertEquals( - (await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"), - "public, no-cache", - ); -}); - -function createdAtOf(id: string): string { - return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as { - created_at: string; - }).created_at; -} - -test("GET /api/files/:id: active content downloads instead of rendering", async () => { - const user = await createUser(); - for (const mime of ["text/html", "image/svg+xml", "application/xml"]) { - const { id } = insertDump({ - userId: user.id, - fileName: "x", - fileMime: mime, - }); - const res = await get(`/api/files/${id}`); - assertStringIncludes( - res.headers.get("content-disposition")!, - "attachment;", - mime, - ); - } - const pdf = insertDump({ - userId: user.id, - fileName: "a.pdf", - fileMime: "application/pdf", - }); - const res = await get(`/api/files/${pdf.id}`); - assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); - assertEquals(res.headers.get("content-security-policy"), null); -}); - -test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => { - const user = await createUser(); - const { id } = insertDump({ - userId: user.id, - fileName: 'mix "été" 🎵.mp3', - fileMime: "audio/mpeg", - }); - const res = await get(`/api/files/${id}`); - assertEquals(res.status, 200); - assertEquals( - res.headers.get("content-disposition"), - `inline; filename="mix __t__ __.mp3"; filename*=UTF-8''${ - encodeURIComponent('mix "été" 🎵.mp3') - }`, - ); -}); - -test("GET /api/files/:id: byte ranges", async () => { - const user = await createUser(); - const { id } = insertDump({ - userId: user.id, - fileName: "d", - fileData: BYTES, - }); - const range = (r: string) => - get(`/api/files/${id}`, { headers: { Range: r } }); - - let res = await range("bytes=2-5"); - assertEquals(res.status, 206); - assertEquals(new TextDecoder().decode(res.bytes), "2345"); - assertEquals(res.headers.get("content-range"), "bytes 2-5/10"); - assertEquals(res.headers.get("content-length"), "4"); - - res = await range("bytes=7-"); - assertEquals(new TextDecoder().decode(res.bytes), "789"); - - res = await range("bytes=-3"); - assertEquals(new TextDecoder().decode(res.bytes), "789"); - assertEquals(res.headers.get("content-range"), "bytes 7-9/10"); - - // A suffix longer than the file is the whole file. - res = await range("bytes=-100"); - assertEquals(new TextDecoder().decode(res.bytes), "0123456789"); - assertEquals(res.headers.get("content-range"), "bytes 0-9/10"); - - res = await range("bytes=5-100"); - assertEquals(new TextDecoder().decode(res.bytes), "56789"); - - for ( - const bad of [ - "bytes=10-", - "bytes=6-2", - "bytes=-0", - "bytes=-", - ] - ) { - res = await range(bad); - assertEquals(res.status, 416, bad); - assertEquals(res.headers.get("content-range"), "bytes */10", bad); - } - - // Several ranges, or a unit other than bytes, aren't refused: the header is - // ignored and the whole file sent, as RFC 9110 allows. - for ( - const unsupported of [ - "bytes=0-0,5-6", - "bytes=0-1, 3-4", - "items=0-1", - "bytes=zero-one", - ] - ) { - res = await range(unsupported); - assertEquals(res.status, 200, unsupported); - assertEquals(new TextDecoder().decode(res.bytes), "0123456789"); - assertEquals(res.headers.get("content-range"), null, unsupported); - assertEquals(res.headers.get("content-length"), "10", unsupported); - } -}); - -test("GET /api/files/:id: private files need the owner's media token", async () => { - const owner = await createUser(); - const other = await createUser(); - const { id } = insertDump({ - userId: owner.id, - fileName: "p", - isPrivate: true, - }); - assertEquals((await get(`/api/files/${id}`)).status, 404); - assertEquals( - (await get(`/api/files/${id}?token=${other.mediaToken}`)).status, - 404, - ); - assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404); - const res = await get(`/api/files/${id}?token=${owner.mediaToken}`); - assertEquals(res.status, 200); - assertEquals(res.headers.get("cache-control"), "private, no-cache"); - // A legacy session token still works in the query string. - assertEquals( - (await get(`/api/files/${id}?token=${owner.token}`)).status, - 200, - ); -}); - -test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => { - const user = await createUser(); - assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400); - const url = insertDump({ userId: user.id }); - assertEquals((await get(`/api/files/${url.id}`)).status, 404); - const gone = insertDump({ userId: user.id, fileName: "g" }); - Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`); - assertEquals((await get(`/api/files/${gone.id}`)).status, 404); -}); - -test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => { - const owner = await createUser(); - const video = insertDump({ - userId: owner.id, - fileName: "v.mp4", - fileMime: "video/mp4", - }); - const priv = insertDump({ - userId: owner.id, - fileName: "p.mp4", - fileMime: "video/mp4", - isPrivate: true, - }); - Deno.mkdirSync(`${TEST_DIR}/uploads/thumbnails`, { recursive: true }); - for (const { id } of [video, priv]) { - Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG); - } - const res = await get(`/api/thumbnails/${video.id}`); - assertEquals(res.status, 200); - assertEquals(res.headers.get("content-type"), "image/jpeg"); - assertEquals(res.headers.get("cache-control"), "public, no-cache"); - const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`; - assertEquals( - (await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).headers.get( - "cache-control", - ), - "public, max-age=31536000, immutable", - ); - assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); - const own = await get( - `/api/thumbnails/${priv.id}?v=${v(priv.id)}&token=${owner.mediaToken}`, - ); - assertEquals(own.status, 200); - assertEquals( - own.headers.get("cache-control"), - "private, max-age=31536000, immutable", - ); - - const text = insertDump({ userId: owner.id, fileName: "t.txt" }); - assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404); - assertEquals((await get(`/api/thumbnails/nope`)).status, 400); -}); - -test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => { - const owner = await createUser(); - const gif = new TextEncoder().encode("GIF89a-not-really"); - const animated = insertDump({ - userId: owner.id, - fileName: "a.gif", - fileMime: "image/gif", - fileData: gif, - }); - const res = await get(`/api/thumbnails/${animated.id}`); - assertEquals(res.status, 200); - assertEquals(res.headers.get("content-type"), "image/gif"); - assertEquals(res.bytes, gif); - - // A real PNG is scaled into a preview, kept beside the video stills. - const photo = insertDump({ - userId: owner.id, - fileName: "p.png", - fileMime: "image/png", - fileData: PNG, - }); - const preview = await get(`/api/thumbnails/${photo.id}`); - assertEquals(preview.status, 200); - assertEquals(preview.headers.get("content-type"), "image/png"); - if (await hasFfmpeg()) { - assertEquals( - readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => - n.startsWith(photo.id) - ), - [`${photo.id}.preview.png`], - ); - } - - // Not an image ffmpeg can read (or no ffmpeg at all): the original goes out. - const broken = insertDump({ - userId: owner.id, - fileName: "b.png", - fileMime: "image/png", - fileData: BYTES, - }); - const fallback = await get(`/api/thumbnails/${broken.id}`); - assertEquals(fallback.status, 200); - assertEquals(fallback.headers.get("content-type"), "image/png"); - assertEquals(fallback.bytes, BYTES); - assertEquals( - readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => - n.startsWith(broken.id) - ), - [], - ); -}); - -async function hasFfmpeg(): Promise { - try { - return (await new Deno.Command("ffmpeg", { - args: ["-version"], - stdout: "null", - stderr: "null", - }).output()).success; - } catch { - return false; - } -} - -function readDir(path: string): string[] { - try { - return [...Deno.readDirSync(path)].map((e) => e.name); - } catch { - return []; - } -} - -test("attachments: upload an image, serve it back", async () => { - const user = await createUser(); - const res = await post("/api/attachments", { - auth: user.auth, - form: formWithFile(PNG), - }); - assertEquals(res.status, 201); - const id = res.data.id; - const served = await get(`/api/attachments/${id}`); - assertEquals(served.status, 200); - assertEquals(served.bytes, PNG); - assertEquals(served.headers.get("content-type"), "image/png"); - assertEquals(served.headers.get("x-content-type-options"), "nosniff"); - assertEquals(served.headers.get("cache-control"), "public, max-age=3600"); - assertEquals( - (await get(`/api/attachments/${crypto.randomUUID()}`)).status, - 404, - ); -}); - -test("attachments: refuses non-images, non-multipart and anonymous uploads", async () => { - const user = await createUser(); - assertEquals( - (await post("/api/attachments", { - auth: user.auth, - form: formWithFile( - new TextEncoder().encode("