diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 4f8d7e9f..04e36e53 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -14,6 +14,7 @@ import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_fa import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts"; import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts"; import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts"; +import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts"; interface Migration { name: string; @@ -42,6 +43,7 @@ const MIGRATIONS: Migration[] = [ { name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical }, { name: "0014_chat_read_state", up: up0014ChatReadState }, { name: "0015_token_revocation", up: up0015TokenRevocation }, + { name: "0016_performance_indexes", up: up0016PerformanceIndexes }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0016_performance_indexes.ts b/api/db/migrations/0016_performance_indexes.ts new file mode 100644 index 00000000..6895204b --- /dev/null +++ b/api/db/migrations/0016_performance_indexes.ts @@ -0,0 +1,34 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Indexes for the queries that ran as full scans: +// - feeds page by created_at (newest first), a user's dumps and upvotes by +// (user_id, created_at) — these replace the bare user_id indexes; +// - dump and playlist pages are looked up by slug; +// - the child side of every ON DELETE CASCADE / SET NULL foreign key, which +// SQLite otherwise scans on each parent delete (a comment's replies, a user's +// comments, follows, chat messages, invites, reset tokens), and the +// followers of a user or playlist, read on every new dump; +// - @mentions resolve usernames case-insensitively, by lower(username). +// +// Idempotent: also runs against fresh databases created from schema.sql, where +// these indexes already exist. +export function up(db: DatabaseSync): void { + db.exec(` + DROP INDEX IF EXISTS idx_dumps_user; + DROP INDEX IF EXISTS idx_votes_user; + CREATE INDEX IF NOT EXISTS idx_dumps_created ON dumps(created_at); + CREATE INDEX IF NOT EXISTS idx_dumps_user_created ON dumps(user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_dumps_slug ON dumps(slug); + CREATE INDEX IF NOT EXISTS idx_votes_user_created ON votes(user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_playlists_slug ON playlists(slug); + CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); + CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id); + CREATE INDEX IF NOT EXISTS idx_follows_followed_user ON follows(followed_user_id); + CREATE INDEX IF NOT EXISTS idx_follows_followed_playlist ON follows(followed_playlist_id); + CREATE INDEX IF NOT EXISTS idx_chat_messages_user ON chat_messages(user_id); + CREATE INDEX IF NOT EXISTS idx_invites_inviter ON invites(inviter_id); + CREATE INDEX IF NOT EXISTS idx_users_invited_by ON users(invited_by); + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_users_username_lower ON users(lower(username)); + `); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 49983982..36c1c79d 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -119,14 +119,19 @@ CREATE TABLE dump_backlinks ( FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE ); -CREATE INDEX idx_dumps_user ON dumps(user_id); +CREATE INDEX idx_dumps_created ON dumps(created_at); +CREATE INDEX idx_dumps_user_created ON dumps(user_id, created_at); +CREATE INDEX idx_dumps_slug ON dumps(slug); CREATE INDEX idx_dumps_url ON dumps(url); CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical); -CREATE INDEX idx_votes_user ON votes(user_id); +CREATE INDEX idx_votes_user_created ON votes(user_id, created_at); CREATE INDEX idx_playlists_user ON playlists(user_id); +CREATE INDEX idx_playlists_slug ON playlists(slug); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); +CREATE INDEX idx_comments_parent ON comments(parent_id); +CREATE INDEX idx_comments_user ON comments(user_id); CREATE INDEX idx_comment_likes_user ON comment_likes(user_id); CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id); CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id); @@ -157,6 +162,8 @@ CREATE UNIQUE INDEX idx_follows_playlist WHERE followed_playlist_id IS NOT NULL; CREATE INDEX idx_follows_follower ON follows(follower_id); +CREATE INDEX idx_follows_followed_user ON follows(followed_user_id); +CREATE INDEX idx_follows_followed_playlist ON follows(followed_playlist_id); CREATE TABLE invites ( token TEXT PRIMARY KEY, @@ -166,6 +173,10 @@ CREATE TABLE invites ( FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE INDEX idx_invites_inviter ON invites(inviter_id); +CREATE INDEX idx_users_invited_by ON users(invited_by); +CREATE INDEX idx_users_username_lower ON users(lower(username)); + CREATE TABLE attachments ( id TEXT PRIMARY KEY, resource_id TEXT, @@ -183,6 +194,8 @@ CREATE TABLE password_reset_tokens ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id); + CREATE TABLE notifications ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -208,3 +221,4 @@ CREATE TABLE chat_messages ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id); +CREATE INDEX idx_chat_messages_user ON chat_messages(user_id); diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts index 74bea872..16ea4789 100644 --- a/api/lib/pagination.ts +++ b/api/lib/pagination.ts @@ -22,3 +22,20 @@ export function parsePagination( ); return { page, limit }; } + +/** One page of a list, and whether another follows. */ +export interface Page { + items: T[]; + hasMore: boolean; +} + +/** + * Turns rows fetched with `LIMIT limit + 1` into a page: the extra row, if it + * came back, is dropped and says there's a next page. No COUNT(*) over the + * whole list just to know that. + */ +export function pageOf(rows: T[], limit: number): Page { + return rows.length > limit + ? { items: rows.slice(0, limit), hasMore: true } + : { items: rows, hasMore: false }; +} diff --git a/api/lib/serve-file.ts b/api/lib/serve-file.ts new file mode 100644 index 00000000..57bb7255 --- /dev/null +++ b/api/lib/serve-file.ts @@ -0,0 +1,154 @@ +import type { Context } from "@oak/oak"; + +const CHUNK_SIZE = 64 * 1024; + +/** + * Streams `length` bytes of an open file, from wherever its cursor stands, + * and closes it once done or cancelled. The response never holds more than a + * chunk of the file in memory — a seek in a 50 MB track reads what it plays. + */ +function fileStream( + file: Deno.FsFile, + length: number, +): ReadableStream { + let remaining = length; + let closed = false; + const close = () => { + if (closed) return; + closed = true; + file.close(); + }; + return new ReadableStream({ + async pull(controller) { + if (remaining <= 0) { + close(); + controller.close(); + return; + } + const buf = new Uint8Array(Math.min(CHUNK_SIZE, remaining)); + let n: number | null; + try { + n = await file.read(buf); + } catch (err) { + close(); + controller.error(err); + return; + } + if (n === null) { + close(); + controller.close(); + return; + } + remaining -= n; + controller.enqueue(buf.subarray(0, n)); + if (remaining <= 0) { + close(); + controller.close(); + } + }, + cancel() { + close(); + }, + }); +} + +/** Whether an If-None-Match header names `etag` (weak comparison). */ +function matchesEtag(header: string | null, etag: string): boolean { + if (!header) return false; + if (header.trim() === "*") return true; + const strip = (tag: string) => tag.trim().replace(/^W\//, ""); + return header.split(",").some((tag) => strip(tag) === strip(etag)); +} + +export interface ServeFileOptions { + mime: string; + cacheControl: string; + /** Answer a single `Range: bytes=…` with a 206 (media files). */ + ranges?: boolean; +} + +/** + * Sends a file from disk with an ETag (a 304 when the client already has it) + * and, when `ranges` is set, single byte ranges. The body is streamed. + * + * Returns false, having sent nothing, when the file doesn't exist. + */ +export async function serveFile( + ctx: Context, + path: string, + { mime, cacheControl, ranges = false }: ServeFileOptions, +): Promise { + let file: Deno.FsFile; + let stat: Deno.FileInfo; + try { + file = await Deno.open(path, { read: true }); + stat = await file.stat(); + } catch { + return false; + } + + const total = stat.size; + const mtime = stat.mtime?.getTime() ?? 0; + const etag = `W/"${total.toString(36)}-${mtime.toString(36)}"`; + const headers = ctx.response.headers; + headers.set("Content-Type", mime); + headers.set("Cache-Control", cacheControl); + headers.set("ETag", etag); + if (stat.mtime) headers.set("Last-Modified", stat.mtime.toUTCString()); + if (ranges) headers.set("Accept-Ranges", "bytes"); + + if (matchesEtag(ctx.request.headers.get("If-None-Match"), etag)) { + file.close(); + ctx.response.status = 304; + return true; + } + + // Only a single byte range is served as one. Anything else — several + // ranges, another unit — is a header RFC 9110 lets us ignore, so the whole + // file goes out with a 200 rather than a 416 for ranges that were fine. + const match = ranges + ? ctx.request.headers.get("Range")?.match(/^bytes=(\d*)-(\d*)$/) + : null; + + if (!match) { + // Explicit: a caller may have tried another file first and left a 404. + ctx.response.status = 200; + headers.set("Content-Length", String(total)); + ctx.response.body = fileStream(file, total); + return true; + } + + // "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes). + let start: number; + let end: number; + if (match[1]) { + start = parseInt(match[1], 10); + end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1; + } else { + const suffix = parseInt(match[2], 10); + start = Math.max(total - suffix, 0); + end = total - 1; + // "bytes=-" and "bytes=-0" name no bytes at all. + if (!(suffix > 0)) start = total; + } + + if (start > end || start >= total) { + file.close(); + ctx.response.status = 416; + headers.set("Content-Range", `bytes */${total}`); + return true; + } + + const length = end - start + 1; + try { + await file.seek(start, Deno.SeekMode.Start); + } catch (err) { + file.close(); + throw err; + } + ctx.response.status = 206; + headers.set("Content-Range", `bytes ${start}-${end}/${total}`); + headers.set("Content-Length", String(length)); + ctx.response.body = fileStream(file, length); + return true; +} diff --git a/api/lib/static.ts b/api/lib/static.ts index db63eaee..c6f44466 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -4,17 +4,30 @@ import { emojiToCodepoint } from "./site-icons.ts"; const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); -async function serveIndexHtml( - context: Context>, - root: string, -) { - const filePath = `${root}/index.html`; - const raw = await Deno.readTextFile(filePath); - const html = raw +// index.html with its placeholders filled, by root. Kept until the file +// changes (a rebuild under a running server): a stat per navigation instead of +// a read and four replaceAll. +const indexHtml = new Map(); + +async function loadIndexHtml(root: string): Promise { + const path = `${root}/index.html`; + const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0; + const cached = indexHtml.get(root); + if (cached && cached.mtime === mtime) return cached.html; + const html = (await Deno.readTextFile(path)) .replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); + indexHtml.set(root, { mtime, html }); + return html; +} + +async function serveIndexHtml( + context: Context>, + root: string, +) { + const html = await loadIndexHtml(root); context.response.type = "text/html"; // Always revalidated: it names the hashed chunks of the current build. context.response.headers.set("Cache-Control", "no-cache"); diff --git a/api/lib/upload.ts b/api/lib/upload.ts index f802a7fb..e37d516a 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 { serveFile } from "./serve-file.ts"; import { ALLOWED_IMAGE_MIMES, ATTACHMENTS_DIR, @@ -67,21 +68,13 @@ export async function serveUploadedFile( cacheMaxAge = 3600, isPrivate = false, ): Promise { - let data: Uint8Array; - try { - data = await Deno.readFile(filePath); - } catch { - ctx.response.status = 404; - return false; - } - ctx.response.headers.set("Content-Type", mime); ctx.response.headers.set("Content-Disposition", "inline"); ctx.response.headers.set("X-Content-Type-Options", "nosniff"); - ctx.response.headers.set( - "Cache-Control", - `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, - ); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); - ctx.response.body = data; - return true; + const served = await serveFile(ctx, filePath, { + mime, + cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, + }); + if (!served) ctx.response.status = 404; + return served; } diff --git a/api/model/db.ts b/api/model/db.ts index 06eab58f..45539652 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -23,9 +23,36 @@ import { import { runMigrations } from "../db/migrate.ts"; export const db = new DatabaseSync(DB_PATH); -db.exec("PRAGMA foreign_keys = ON;"); +// 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;`, diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 0074f29c..f1531af5 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -338,8 +338,9 @@ export type APIResponse = APISuccess | APIFailure; export interface PaginatedData { items: T[]; - total: number; hasMore: boolean; + /** The whole list's size — only where it's shown (search results). */ + total?: number; } export interface SearchResults { diff --git a/api/routes/categories.ts b/api/routes/categories.ts index c26b5a96..a4453825 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -45,7 +45,7 @@ router.get("/:slug", (ctx) => { router.get("/:slug/dumps", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = listDumpsByCategory( + const { items, hasMore } = listDumpsByCategory( ctx.params.slug, page, limit, @@ -53,7 +53,7 @@ router.get("/:slug/dumps", async (ctx) => { ); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); diff --git a/api/routes/comments.ts b/api/routes/comments.ts index bb0d6846..de2b4a1c 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -96,7 +96,7 @@ router.get("/comments/:commentId/likers", async (ctx) => { const dumpId = getCommentDumpId(ctx.params.commentId); getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getCommentLikers( + const { items, hasMore } = getCommentLikers( ctx.params.commentId, page, limit, @@ -105,8 +105,7 @@ router.get("/comments/:commentId/likers", async (ctx) => { success: true, data: { items: items.map(toPublicUser), - total, - hasMore: page * limit < total, + hasMore, }, }; ctx.response.body = responseBody; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index bf65e49b..e63054d3 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -130,13 +130,12 @@ router.get("/:dumpId/voters", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getDumpVoters(dump.id, page, limit); + const { items, hasMore } = getDumpVoters(dump.id, page, limit); const responseBody: APIResponse> = { success: true, data: { items: items.map(toPublicUser), - total, - hasMore: page * limit < total, + hasMore, }, }; ctx.response.body = responseBody; @@ -165,10 +164,10 @@ router.get("/:dumpId/playlists", async (ctx) => { router.get("/", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = listDumps(page, limit, requestingUserId); + const { items, hasMore } = listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); diff --git a/api/routes/files.ts b/api/routes/files.ts index feac7827..87316869 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,8 +1,9 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { getDump } from "../services/dump-service.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; import { authenticateMedia } from "../lib/auth.ts"; +import { serveFile } from "../lib/serve-file.ts"; +import { dumpFileVersion, getDump } from "../services/dump-service.ts"; const router = new Router({ prefix: "/api/files" }); @@ -63,17 +64,7 @@ router.get("/:dumpId", async (ctx) => { } const path = `${DUMPS_DIR}/${dumpId}`; - - let data: Uint8Array; - try { - data = await Deno.readFile(path); - } catch { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); - } - - const total = data.byteLength; const inline = isInlineSafe(dump.fileMime); - ctx.response.headers.set("Content-Type", dump.fileMime); ctx.response.headers.set( "Content-Disposition", contentDisposition(inline ? "inline" : "attachment", dump.fileName), @@ -91,46 +82,23 @@ router.get("/:dumpId", async (ctx) => { // The URL may carry a media token; don't pass it on to wherever the file // links. ctx.response.headers.set("Referrer-Policy", "no-referrer"); - // A private dump's bytes must not sit in a shared cache. - if (dump.isPrivate) ctx.response.headers.set("Cache-Control", "private"); - ctx.response.headers.set("Accept-Ranges", "bytes"); - // Only a single byte range is served as one. Anything else — several - // ranges, another unit — is a header RFC 9110 lets us ignore, so the whole - // file goes out with a 200 rather than a 416 for ranges that were fine. - const match = ctx.request.headers.get("Range")?.match( - /^bytes=(\d*)-(\d*)$/, - ); - - if (match) { - // "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes). - let start: number; - let end: number; - if (match[1]) { - start = parseInt(match[1], 10); - end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1; - } else { - const suffix = parseInt(match[2], 10); - start = Math.max(total - suffix, 0); - end = total - 1; - // "bytes=-" and "bytes=-0" name no bytes at all. - if (!(suffix > 0)) start = total; - } - - if (start > end || start >= total) { - ctx.response.status = 416; - ctx.response.headers.set("Content-Range", `bytes */${total}`); - return; - } - - const chunk = data.subarray(start, end + 1); - ctx.response.status = 206; - ctx.response.headers.set("Content-Range", `bytes ${start}-${end}/${total}`); - ctx.response.headers.set("Content-Length", String(chunk.byteLength)); - ctx.response.body = chunk; - } else { - ctx.response.headers.set("Content-Length", String(total)); - ctx.response.body = data; + // The app's URLs carry the file's version (see dumpFileVersion), so one + // naming the current version never changes and is cached for good; any + // other URL is revalidated against the ETag. A private dump's bytes must not + // sit in a shared cache. + const scope = dump.isPrivate ? "private" : "public"; + const current = ctx.request.url.searchParams.get("v") === + dumpFileVersion(dump); + const served = await serveFile(ctx, path, { + mime: dump.fileMime, + cacheControl: current + ? `${scope}, max-age=31536000, immutable` + : `${scope}, no-cache`, + ranges: true, + }); + if (!served) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); } }); diff --git a/api/routes/follows.ts b/api/routes/follows.ts index 4fd8f667..b51ea040 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -6,7 +6,7 @@ import { type FollowStatus, type PaginatedData, } from "../model/interfaces.ts"; -import { parsePagination } from "../lib/pagination.ts"; +import { type Page, parsePagination } from "../lib/pagination.ts"; import { followPlaylist, followUser, @@ -29,7 +29,7 @@ function resolveCategoryFilter( return getCategoryIdBySlug(slug) ?? "missing"; } -const EMPTY_FEED: { items: Dump[]; total: number } = { items: [], total: 0 }; +const EMPTY_FEED: Page = { items: [], hasMore: false }; const router = new Router({ prefix: "/api/follows" }); @@ -46,13 +46,12 @@ router.get("/status", authMiddleware, (ctx) => { router.get("/feed/users", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); - const { items, total } = categoryId === "missing" + const { items, hasMore } = categoryId === "missing" ? EMPTY_FEED : getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId); const data: PaginatedData = { items, - total, - hasMore: page * limit < total, + hasMore, }; const body: APIResponse> = { success: true, data }; ctx.response.body = body; @@ -62,7 +61,7 @@ router.get("/feed/users", authMiddleware, (ctx) => { router.get("/feed/playlists", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); - const { items, total } = categoryId === "missing" + const { items, hasMore } = categoryId === "missing" ? EMPTY_FEED : getFollowedPlaylistsDumpFeed( ctx.state.user.userId, @@ -72,8 +71,7 @@ router.get("/feed/playlists", authMiddleware, (ctx) => { ); const data: PaginatedData = { items, - total, - hasMore: page * limit < total, + hasMore, }; const body: APIResponse> = { success: true, data }; ctx.response.body = body; diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 30b0f36c..53e3273e 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -22,14 +22,14 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); } const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getNotificationsForUser( + const { items, hasMore } = getNotificationsForUser( ctx.state.user.userId, page, limit, ); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 30232545..0f258227 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -50,6 +50,74 @@ 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, + contentType: string, +): void { + ctx.response.headers.set("Content-Type", contentType); + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + ctx.response.headers.set( + "Content-Security-Policy", + "sandbox; default-src 'none'", + ); + ctx.response.headers.set("Cache-Control", "public, max-age=86400"); + ctx.response.body = bytes; +} + /** * Proxy an external image through the server so HTTP thumbnail URLs don't * trigger mixed-content blocks when the frontend is served over HTTPS. @@ -61,6 +129,12 @@ 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 @@ -88,14 +162,8 @@ previewRouter.get("/api/proxy-image", async (ctx) => { return; } - ctx.response.headers.set("Content-Type", contentType); - ctx.response.headers.set("X-Content-Type-Options", "nosniff"); - ctx.response.headers.set( - "Content-Security-Policy", - "sandbox; default-src 'none'", - ); - ctx.response.headers.set("Cache-Control", "public, max-age=86400"); - ctx.response.body = bytes; + writeImageCache(url, bytes, contentType); + sendImage(ctx, bytes, contentType); } catch { ctx.response.status = 502; } diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 40e4d774..a2fd3ab4 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -1,59 +1,128 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts"; +import { + dumpFileVersion, + getDump, + getDumpThumbnailInfo, +} from "../services/dump-service.ts"; +import { serveFile } from "../lib/serve-file.ts"; import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; const router = new Router({ prefix: "/api/thumbnails" }); -async function ffmpegAvailable(): Promise { - try { - const cmd = new Deno.Command("ffmpeg", { - args: ["-version"], - stderr: "null", - stdout: "null", - }); - const { success } = await cmd.output(); - return success; - } catch { - return false; - } +// Probed once per process: spawning `ffmpeg -version` on every uncached +// thumbnail request cost a process per card. +let ffmpegProbe: Promise | undefined; + +function ffmpegAvailable(): Promise { + ffmpegProbe ??= (async () => { + try { + const { success } = await new Deno.Command("ffmpeg", { + args: ["-version"], + stderr: "null", + stdout: "null", + }).output(); + return success; + } catch { + return false; + } + })(); + return ffmpegProbe; } -async function generateThumbnail( - srcPath: string, - outPath: string, -): Promise { +// The upload's type is only what the uploader claimed, and ffmpeg probes the +// real format from the bytes — a "video" could be an HLS or concat playlist +// pointing ffmpeg at local files or internal URLs. Read nothing but the file +// itself, and only as the kind of container it's meant to be. +const VIDEO_FORMATS = + "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc"; +const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; + +/** Images the feed shows through a scaled-down preview. GIFs keep their + * animation (served as they are); anything else has no preview to make. */ +const PREVIEWABLE_IMAGES: Record = { + "image/jpeg": "jpg", + "image/bmp": "jpg", + // May carry transparency, which JPEG would fill with black. + "image/png": "png", + "image/webp": "png", +}; + +/** Width of an image preview: comfortably covers the widest feed card on a + * high-density screen, a fraction of a phone photo's bytes. */ +const PREVIEW_WIDTH = 960; + +async function runFfmpeg(args: string[], outPath: string): Promise { await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); - const cmd = new Deno.Command("ffmpeg", { - args: [ - // The upload's type is only what the uploader claimed, and ffmpeg probes - // the real format from the bytes — a "video" could be an HLS or concat - // playlist pointing ffmpeg at local files or internal URLs. Read nothing - // but the file itself, and only as an actual video container. - "-protocol_whitelist", - "file", - "-format_whitelist", - "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc", - "-ss", - "00:00:01", - "-i", - srcPath, - "-frames:v", - "1", - "-vf", - "scale=320:-1", - "-f", - "image2", - "-y", - outPath, - ], + // Written beside and renamed over, so a concurrent reader never gets half. + const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`; + const { success } = await new Deno.Command("ffmpeg", { + args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath], stdout: "null", stderr: "null", - }); - const { success } = await cmd.output(); - if (!success) throw new Error("ffmpeg failed"); + }).output(); + if (!success) { + await Deno.remove(tmpPath).catch(() => {}); + throw new Error("ffmpeg failed"); + } + await Deno.rename(tmpPath, outPath); +} + +function videoStill(srcPath: string, outPath: string): Promise { + return runFfmpeg([ + "-format_whitelist", + VIDEO_FORMATS, + "-ss", + "00:00:01", + "-i", + srcPath, + "-frames:v", + "1", + "-vf", + "scale=320:-1", + "-f", + "image2", + "-c:v", + "mjpeg", + ], outPath); +} + +function imagePreview(srcPath: string, outPath: string): Promise { + return runFfmpeg([ + "-format_whitelist", + IMAGE_FORMATS, + "-i", + srcPath, + "-frames:v", + "1", + "-vf", + `scale='min(${PREVIEW_WIDTH},iw)':-2`, + "-f", + "image2", + ...(outPath.endsWith(".png") + ? ["-c:v", "png"] + : ["-c:v", "mjpeg", "-q:v", "4"]), + ], outPath); +} + +// One ffmpeg run per output: a feed full of cards asking for the same new +// still at once waits on a single generation. +const inFlight = new Map>(); + +async function ensureGenerated( + outPath: string, + generate: () => Promise, +): Promise { + if (await Deno.stat(outPath).then(() => true, () => false)) return true; + if (!await ffmpegAvailable()) return false; + let pending = inFlight.get(outPath); + if (!pending) { + pending = generate().finally(() => inFlight.delete(outPath)); + inFlight.set(outPath, pending); + } + return await pending.then(() => true, () => false); } router.get("/:dumpId", async (ctx) => { @@ -70,67 +139,80 @@ router.get("/:dumpId", async (ctx) => { const dump = getDump(dumpId, viewerId ?? undefined); // The URL may carry a media token; keep it out of anyone else's logs. ctx.response.headers.set("Referrer-Policy", "no-referrer"); - // A private dump's thumbnail must not sit in a shared cache. - const cacheControl = dump.isPrivate - ? "private, max-age=31536000, immutable" - : "public, max-age=31536000, immutable"; + // A private dump's thumbnail must not sit in a shared cache. The app's URLs + // carry the file's version (see dumpFileVersion): what's generated from the + // file is cached for good under the current one, and revalidated otherwise. + const scope = dump.isPrivate ? "private" : "public"; + const generatedCache = + ctx.request.url.searchParams.get("v") === dumpFileVersion(dump) + ? `${scope}, max-age=31536000, immutable` + : `${scope}, no-cache`; const customThumb = getDumpThumbnailInfo(dumpId); if (customThumb) { + // Replaceable without the URL changing: always revalidated (a 304 when + // unchanged). const served = await serveUploadedFile( ctx, `${THUMBNAILS_DIR}/${customThumb.id}-custom`, customThumb.mime, - 3600, + 0, dump.isPrivate, ); if (served) return; } - if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "No video for this dump", - ); + const mime = dump.kind === "file" ? dump.fileMime ?? "" : ""; + const srcPath = `${DUMPS_DIR}/${dump.id}`; + + if (mime.startsWith("video/")) { + const thumbPath = `${THUMBNAILS_DIR}/${dump.id}.jpg`; + if ( + !await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath)) + ) { + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "Could not generate thumbnail", + ); + } + if ( + await serveFile(ctx, thumbPath, { + mime: "image/jpeg", + cacheControl: generatedCache, + }) + ) return; } - const thumbPath = `${THUMBNAILS_DIR}/${dumpId}.jpg`; - - // Serve cached thumbnail if it exists - try { - const data = await Deno.readFile(thumbPath); - ctx.response.headers.set("Content-Type", "image/jpeg"); - ctx.response.headers.set("Cache-Control", cacheControl); - ctx.response.body = data; - return; - } catch { - // Not cached yet — generate it + if (mime.startsWith("image/")) { + // A scaled-down copy for the feed; the original when there's none to make + // (a GIF, no ffmpeg, an image ffmpeg can't read). + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + const ext = PREVIEWABLE_IMAGES[mime]; + if (ext) { + const previewPath = `${THUMBNAILS_DIR}/${dump.id}.preview.${ext}`; + if ( + await ensureGenerated( + previewPath, + () => imagePreview(srcPath, previewPath), + ) && + await serveFile(ctx, previewPath, { + mime: ext === "png" ? "image/png" : "image/jpeg", + cacheControl: generatedCache, + }) + ) return; + } + if ( + /^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) && + await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache }) + ) return; } - if (!await ffmpegAvailable()) { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Thumbnail generation unavailable", - ); - } - - const srcPath = `${DUMPS_DIR}/${dumpId}`; - try { - await generateThumbnail(srcPath, thumbPath); - } catch { - throw new APIException( - APIErrorCode.NOT_FOUND, - 404, - "Could not generate thumbnail", - ); - } - - const data = await Deno.readFile(thumbPath); - ctx.response.headers.set("Content-Type", "image/jpeg"); - ctx.response.headers.set("Cache-Control", cacheControl); - ctx.response.body = data; + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "No thumbnail for this dump", + ); }); export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index 81d839c8..a023d529 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -380,13 +380,12 @@ router.get("/by-id/:userId", (ctx) => { router.get("/:username/followed-users", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getFollowedUsersByUser(user.id, page, limit); + const { items, hasMore } = getFollowedUsersByUser(user.id, page, limit); const responseBody: APIResponse> = { success: true, data: { items: items.map(toPublicUser), - total, - hasMore: page * limit < total, + hasMore, }, }; ctx.response.body = responseBody; @@ -396,10 +395,10 @@ router.get("/:username/followed-users", (ctx) => { router.get("/:username/followed-playlists", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); + const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); @@ -409,7 +408,7 @@ router.get("/:username/playlists", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = listPlaylistsByUser( + const { items, hasMore } = listPlaylistsByUser( user.id, requestingUserId, page, @@ -417,7 +416,7 @@ router.get("/:username/playlists", async (ctx) => { ); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); @@ -447,10 +446,15 @@ router.get("/:username/dumps", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const includePrivate = requestingUserId === user.id; - const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); + const { items, hasMore } = getDumpsByUser( + user.id, + page, + limit, + includePrivate, + ); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); @@ -460,7 +464,7 @@ router.get("/:username/votes", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getVotedDumpsByUser( + const { items, hasMore } = getVotedDumpsByUser( user.id, page, limit, @@ -468,7 +472,7 @@ router.get("/:username/votes", async (ctx) => { ); const responseBody: APIResponse> = { success: true, - data: { items, total, hasMore: page * limit < total }, + data: { items, hasMore }, }; ctx.response.body = responseBody; }); diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index 013aefee..b4ee1cf6 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -1,4 +1,4 @@ -import { db } from "../model/db.ts"; +import { db, transaction } from "../model/db.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; export function createAttachment(id: string, mime: string): void { @@ -34,9 +34,11 @@ const ATTACHMENT_RE = */ export function linkAttachments(text: string, resourceId: string): void { const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); - for (const id of ids) { - db.prepare( - "UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL", - ).run(resourceId, id); - } + if (ids.length === 0) return; + const update = db.prepare( + "UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL", + ); + transaction(() => { + for (const id of ids) update.run(resourceId, id); + }); } diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index 267c6492..3a438e30 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -4,6 +4,7 @@ import { DUMP_SELECT_COLUMNS as SELECT_COLS, dumpRowToApi, isDumpRow, + transaction, } from "../model/db.ts"; import { UUID_RE } from "../lib/slugify.ts"; @@ -78,23 +79,19 @@ export function relinkBacklinks( targetIds.delete(fromDumpId); // no self-links - db.exec("BEGIN;"); - try { + transaction(() => { db.prepare( `DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, ).run(sourceType, sourceId); const createdAt = new Date().toISOString(); + const insert = db.prepare( + `INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at) + VALUES (?, ?, ?, ?, ?);`, + ); for (const toDumpId of targetIds) { - db.prepare( - `INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at) - VALUES (?, ?, ?, ?, ?);`, - ).run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); + insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); } - db.exec("COMMIT;"); - } catch (err) { - db.exec("ROLLBACK;"); - throw err; - } + }); } /** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */ diff --git a/api/services/bandcamp-stream-service.ts b/api/services/bandcamp-stream-service.ts index 45e4cee6..e8fed23e 100644 --- a/api/services/bandcamp-stream-service.ts +++ b/api/services/bandcamp-stream-service.ts @@ -1,5 +1,9 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { fetchWithTimeout, isValidHttpUrl } from "./rich-content-service.ts"; +import { + fetchWithTimeout, + isValidHttpUrl, + readTextCapped, +} from "./rich-content-service.ts"; import { BANDCAMP_HOST_RE, parseTralbum, @@ -121,7 +125,7 @@ export async function resolveBandcamp( ); } - const parsed = parseTralbum(await res.text(), pageUrl); + const parsed = parseTralbum(await readTextCapped(res), pageUrl); if (!parsed) { // Covers "no data-tralbum", "hasAudio: false" and preorder-only releases // with nothing streamable — all cases where the client falls back to the diff --git a/api/services/category-service.ts b/api/services/category-service.ts index b639f023..c0c1b972 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -13,7 +13,9 @@ import { dumpRowToApi, isCategoryRow, isDumpRow, + transaction, } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { VALIDATION } from "../config.ts"; @@ -217,13 +219,15 @@ export function setDumpCategories( assertCategoriesExist(categoryIds); const unique = [...new Set(categoryIds)]; - db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId); - const insert = db.prepare( - `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, - ); - for (const categoryId of unique) { - insert.run(dumpId, categoryId); - } + transaction(() => { + db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId); + const insert = db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ); + for (const categoryId of unique) { + insert.run(dumpId, categoryId); + } + }); } /** Fill `categoryIds` on each dump with a single batched lookup. */ @@ -251,7 +255,7 @@ export function listDumpsByCategory( page: number, limit: number, requestingUserId?: string, -): { items: Dump[]; total: number } { +): Page { const category = getCategoryBySlug(slug); const offset = (page - 1) * limit; @@ -266,16 +270,9 @@ export function listDumpsByCategory( INNER JOIN dump_categories dc ON dc.dump_id = d.id WHERE dc.category_id = ? AND ${privacyClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, - ).all(category.id, ...privacyParams, limit, offset); - - const totalRow = db.prepare( - `SELECT COUNT(*) as count - FROM dumps d - INNER JOIN dump_categories dc ON dc.dump_id = d.id - WHERE dc.category_id = ? AND ${privacyClause};`, - ).get(category.id, ...privacyParams) as { count: number } | undefined; + ).all(category.id, ...privacyParams, limit + 1, offset); const items = rows.filter(isDumpRow).map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index 700e1410..5c630c71 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -1,5 +1,6 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts"; import { db, isUserRow, userRowToApi } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; export interface LikeResult { @@ -105,13 +106,9 @@ export function getCommentLikers( commentId: string, page: number, limit: number, -): { items: User[]; total: number } { +): Page { const offset = (page - 1) * limit; - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM comment_likes WHERE comment_id = ?;`, - ).get(commentId) as { count: number } | undefined; - const rawRows = db.prepare( `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, @@ -121,7 +118,7 @@ export function getCommentLikers( INNER JOIN comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id ORDER BY cl.created_at DESC LIMIT ? OFFSET ?;`, - ).all(commentId, limit, offset); + ).all(commentId, limit + 1, offset); if (!rawRows.every(isUserRow)) { throw new APIException( @@ -131,5 +128,5 @@ export function getCommentLikers( ); } - return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 }; + return pageOf(rawRows.map(userRowToApi), limit); } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 410c4b03..502bd19e 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -13,7 +13,9 @@ import { dumpApiToRow, dumpRowToApi, isDumpRow, + transaction, } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { fetchRichContent, isValidHttpUrl, @@ -97,25 +99,26 @@ export async function createUrlDump( const isPrivate = request.isPrivate ?? false; const slug = makeSlug(title, dumpId); - db.prepare( - `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, - ).run( - dumpId, - "url", - title, - slug, - request.comment ?? null, - userId, - createdAt.toISOString(), - request.url, - canonicalizeUrl(request.url), - richContent ? JSON.stringify(richContent) : null, - isPrivate ? 1 : 0, - ); - const categoryIds = [...new Set(request.categoryIds ?? [])]; - setDumpCategories(dumpId, categoryIds); + transaction(() => { + db.prepare( + `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, + ).run( + dumpId, + "url", + title, + slug, + request.comment ?? null, + userId, + createdAt.toISOString(), + request.url, + canonicalizeUrl(request.url), + richContent ? JSON.stringify(richContent) : null, + isPrivate ? 1 : 0, + ); + setDumpCategories(dumpId, categoryIds); + }); const dump: Dump = { id: dumpId, @@ -176,28 +179,30 @@ export async function createFileDump( const dedupedCategoryIds = [...new Set(categoryIds)]; await Deno.mkdir(DUMPS_DIR, { recursive: true }); - const data = new Uint8Array(await file.arrayBuffer()); try { - await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data); + // Streamed to disk rather than copied into one more 50 MB buffer first. + await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream()); - db.prepare( - `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) + transaction(() => { + db.prepare( + `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, - ).run( - dumpId, - "file", - finalTitle, - slug, - comment ?? null, - userId, - createdAt.toISOString(), - file.name, - file.type, - file.size, - isPrivate ? 1 : 0, - ); - setDumpCategories(dumpId, dedupedCategoryIds); + ).run( + dumpId, + "file", + finalTitle, + slug, + comment ?? null, + userId, + createdAt.toISOString(), + file.name, + file.type, + file.size, + isPrivate ? 1 : 0, + ); + setDumpCategories(dumpId, dedupedCategoryIds); + }); } catch (err) { // Roll back the file if DB insert fails await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); @@ -232,6 +237,29 @@ export async function createFileDump( return dump; } +/** + * A file dump's version as the app puts it in file and thumbnail URLs + * (`?v=`): it changes whenever the file can have. Mirrored by + * `dumpFileVersion` in src/utils/urls.ts. + */ +export function dumpFileVersion( + dump: Pick, +): string { + return `${dump.fileSize ?? 0}-${ + (dump.updatedAt ?? dump.createdAt).getTime() + }`; +} + +/** Files generated from a dump's upload (see api/routes/thumbnails.ts): + * dropped when it's replaced or gone, so they're made again from the new one. */ +async function removeGeneratedThumbnails(dumpId: string): Promise { + await Promise.all( + [`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map( + (name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}), + ), + ); +} + // Internal fetch — no privacy check. Use only when ownership is already enforced. function fetchDump(idOrSlug: string): Dump { const row = UUID_RE.test(idOrSlug) @@ -296,23 +324,16 @@ export function listDumps( page: number, limit: number, requestingUserId?: string, -): { items: Dump[]; total: number } { +): Page { const offset = (page - 1) * limit; // Show public dumps + the requesting user's own private dumps const rows = requestingUserId ? db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(requestingUserId, limit, offset) + ).all(requestingUserId, limit + 1, offset) : db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(limit, offset); - const totalRow = requestingUserId - ? db.prepare( - `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?);`, - ).get(requestingUserId) as { count: number } | undefined - : db.prepare( - `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0;`, - ).get() as { count: number } | undefined; + ).all(limit + 1, offset); if (!rows || !rows.every(isDumpRow)) { throw new APIException( @@ -324,7 +345,7 @@ export function listDumps( const items = rows.map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } /** @@ -548,11 +569,24 @@ export async function replaceFileDump( } if (categoryIds !== undefined) assertCategoriesExist(categoryIds); - const data = new Uint8Array(await file.arrayBuffer()); + // The new file is written beside the old one and renamed over it (atomic: a + // reader sees one or the other). A hard link keeps the old one reachable so + // a failed update can put it back, without holding either in memory. const filePath = `${DUMPS_DIR}/${dumpId}`; - // Read old file contents so we can restore on DB failure - const oldData = await Deno.readFile(filePath).catch(() => null); - await Deno.writeFile(filePath, data); + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + const backupPath = `${tmpPath}.old`; + await Deno.mkdir(DUMPS_DIR, { recursive: true }); + try { + await Deno.writeFile(tmpPath, file.stream()); + } catch (err) { + await Deno.remove(tmpPath).catch(() => {}); + throw err; + } + const hadOld = await Deno.link(filePath, backupPath).then( + () => true, + () => false, + ); + await Deno.rename(tmpPath, filePath); const now = new Date(); const finalTitle = title?.trim() || dump.title; @@ -561,30 +595,35 @@ export async function replaceFileDump( : dump.slug; const finalPrivate = isPrivate ?? dump.isPrivate; try { - db.prepare( - `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, - ).run( - finalTitle, - newSlug ?? null, - file.name, - file.type, - file.size, - comment ?? null, - finalPrivate ? 1 : 0, - now.toISOString(), - dumpId, - ); + transaction(() => { + db.prepare( + `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + ).run( + finalTitle, + newSlug ?? null, + file.name, + file.type, + file.size, + comment ?? null, + finalPrivate ? 1 : 0, + now.toISOString(), + dumpId, + ); + if (categoryIds !== undefined) { + setDumpCategories(dumpId, [...new Set(categoryIds)]); + } + }); } catch (err) { - // Roll back the file to its previous contents on DB failure - if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {}); + // Put the previous file back on DB failure + if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {}); else await Deno.remove(filePath).catch(() => {}); throw err; } + if (hadOld) await Deno.remove(backupPath).catch(() => {}); + await removeGeneratedThumbnails(dumpId); if (categoryIds !== undefined) { - const ids = [...new Set(categoryIds)]; - setDumpCategories(dumpId, ids); - dump.categoryIds = ids; + dump.categoryIds = [...new Set(categoryIds)]; } else { attachCategoryIds([dump]); } @@ -616,15 +655,12 @@ export function getDumpsByUser( page: number, limit: number, includePrivate: boolean, -): { items: Dump[]; total: number } { +): Page { const offset = (page - 1) * limit; const privacyFilter = includePrivate ? "" : " AND is_private = 0"; const rows = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit, offset); - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`, - ).get(userId) as { count: number } | undefined; + ).all(userId, limit + 1, offset); if (!rows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, @@ -634,7 +670,7 @@ export function getDumpsByUser( } const items = rows.map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } export function getVotedDumpsByUser( @@ -642,11 +678,9 @@ export function getVotedDumpsByUser( page: number, limit: number, requestingUserId: string | null, -): { items: Dump[]; total: number } { +): Page { const offset = (page - 1) * limit; const dumpCols = SELECT_COLS_ALIASED; - - let totalRow: { count: number } | undefined; let rows: unknown[]; if (requestingUserId === userId) { @@ -657,12 +691,7 @@ export function getVotedDumpsByUser( INNER JOIN votes v ON d.id = v.dump_id WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?) ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, userId, limit, offset); - totalRow = db.prepare( - `SELECT COUNT(*) as count FROM dumps d - INNER JOIN votes v ON d.id = v.dump_id - WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`, - ).get(userId, userId) as { count: number } | undefined; + ).all(userId, userId, limit + 1, offset); } else { rows = db.prepare( `SELECT ${dumpCols} @@ -670,12 +699,7 @@ export function getVotedDumpsByUser( INNER JOIN votes v ON d.id = v.dump_id WHERE v.user_id = ? AND d.is_private = 0 ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit, offset); - totalRow = db.prepare( - `SELECT COUNT(*) as count FROM dumps d - INNER JOIN votes v ON d.id = v.dump_id - WHERE v.user_id = ? AND d.is_private = 0;`, - ).get(userId) as { count: number } | undefined; + ).all(userId, limit + 1, offset); } if (!rows.every(isDumpRow)) { @@ -687,7 +711,7 @@ export function getVotedDumpsByUser( } const items = rows.map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } export async function refreshDumpMetadata(dumpId: string): Promise { @@ -705,7 +729,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise { // over a dump that already has good metadata would silently destroy it and // rename the dump to its bare hostname, so bail out instead — the caller // surfaces the error and nothing is lost. - const { ok, content: richContent } = await tryFetchRichContent(dump.url); + const { ok, content: richContent } = await tryFetchRichContent(dump.url, { + fresh: true, + }); if (!ok) { throw new APIException( APIErrorCode.SERVER_ERROR, @@ -767,7 +793,11 @@ export async function deleteDump(dumpId: string): Promise { } if (dump.kind === "file") { - await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); + await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {}); + await removeGeneratedThumbnails(dump.id); + } + if (dump.thumbnailMime) { + await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {}); } broadcastDumpDeleted(dumpId); diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index bfccd3b0..b2089480 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -21,6 +21,7 @@ import { playlistRowToApi, userRowToApi, } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds } from "./category-service.ts"; // ── Follow / unfollow a user ────────────────────────────────────────────────── @@ -141,7 +142,7 @@ export function getFollowedUsersDumpFeed( page: number, limit: number, categoryId?: string, -): { items: Dump[]; total: number } { +): Page { const offset = (page - 1) * limit; // Optional category filter: join the membership table and constrain to it. @@ -159,16 +160,7 @@ export function getFollowedUsersDumpFeed( AND d.is_private = 0 ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, - ).all(...catParams, followerId, limit, offset); - - const totalRow = db.prepare( - `SELECT COUNT(*) as count - FROM dumps d - INNER JOIN follows f ON f.followed_user_id = d.user_id - ${catJoin} - WHERE f.follower_id = ? - AND d.is_private = 0;`, - ).get(...catParams, followerId) as { count: number } | undefined; + ).all(...catParams, followerId, limit + 1, offset); const userFeedRows = rawRows as Parameters[0][]; if (!userFeedRows.every(isDumpRow)) { @@ -180,7 +172,7 @@ export function getFollowedUsersDumpFeed( } const items = userFeedRows.map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } // ── Followed-playlists dump feed ────────────────────────────────────────────── @@ -190,7 +182,7 @@ export function getFollowedPlaylistsDumpFeed( page: number, limit: number, categoryId?: string, -): { items: Dump[]; total: number } { +): Page { const offset = (page - 1) * limit; const catJoin = categoryId @@ -211,19 +203,7 @@ export function getFollowedPlaylistsDumpFeed( GROUP BY d.id ORDER BY MAX(pd.added_at) DESC LIMIT ? OFFSET ?;`, - ).all(...catParams, followerId, limit, offset); - - const totalRow = db.prepare( - `SELECT COUNT(DISTINCT d.id) as count - FROM dumps d - INNER JOIN playlist_dumps pd ON pd.dump_id = d.id - INNER JOIN playlists p ON p.id = pd.playlist_id - INNER JOIN follows f ON f.followed_playlist_id = p.id - ${catJoin} - WHERE f.follower_id = ? - AND p.is_public = 1 - AND d.is_private = 0;`, - ).get(...catParams, followerId) as { count: number } | undefined; + ).all(...catParams, followerId, limit + 1, offset); if (!rawRows.every(isDumpRow)) { throw new APIException( @@ -234,7 +214,7 @@ export function getFollowedPlaylistsDumpFeed( } const items = rawRows.map(dumpRowToApi); attachCategoryIds(items); - return { items, total: totalRow?.count ?? 0 }; + return pageOf(items, limit); } // ── Followed playlists (as playlist objects) ────────────────────────────────── @@ -243,15 +223,9 @@ export function getFollowedPlaylistsByUser( userId: string, page: number, limit: number, -): { items: Playlist[]; total: number } { +): Page { const offset = (page - 1) * limit; - const totalRow = db.prepare( - `SELECT COUNT(*) as count - FROM follows f - WHERE f.follower_id = ? AND f.followed_playlist_id IS NOT NULL;`, - ).get(userId) as { count: number } | undefined; - const rawRows = db.prepare( `SELECT p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count @@ -262,7 +236,7 @@ export function getFollowedPlaylistsByUser( AND p.is_public = 1 ORDER BY f.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit, offset); + ).all(userId, limit + 1, offset); if (!rawRows.every(isPlaylistRow)) { throw new APIException( @@ -271,7 +245,7 @@ export function getFollowedPlaylistsByUser( "Malformed playlist data", ); } - return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 }; + return pageOf(rawRows.map(playlistRowToApi), limit); } // ── Followed users (as user objects) ───────────────────────────────────────── @@ -280,13 +254,9 @@ export function getFollowedUsersByUser( userId: string, page: number, limit: number, -): { items: User[]; total: number } { +): Page { const offset = (page - 1) * limit; - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`, - ).get(userId) as { count: number } | undefined; - const rawRows = db.prepare( `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, @@ -297,7 +267,7 @@ export function getFollowedUsersByUser( WHERE f.follower_id = ? ORDER BY f.created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit, offset); + ).all(userId, limit + 1, offset); if (!rawRows.every(isUserRow)) { throw new APIException( @@ -307,5 +277,5 @@ export function getFollowedUsersByUser( ); } - return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 }; + return pageOf(rawRows.map(userRowToApi), limit); } diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index a859282f..3237a64d 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -5,7 +5,13 @@ import type { UserDumpPostedData, } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; -import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts"; +import { + db, + isNotificationRow, + notificationRowToApi, + transaction, +} from "../model/db.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 (...) @@ -54,15 +60,11 @@ export function getNotificationsForUser( userId: string, page: number, limit: number, -): { items: Notification[]; total: number } { +): Page { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`, - ).all(userId, limit, offset); - - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`, - ).get(userId) as { count: number } | undefined; + ).all(userId, limit + 1, offset); if (!rawRows.every(isNotificationRow)) { throw new APIException( @@ -71,10 +73,7 @@ export function getNotificationsForUser( "Malformed notification data", ); } - return { - items: rawRows.map(notificationRowToApi), - total: totalRow?.count ?? 0, - }; + return pageOf(rawRows.map(notificationRowToApi), limit); } export function getUnreadCount(userId: string): number { @@ -179,30 +178,35 @@ export function notifyUserFollowersNewDump( VALUES (?, ?, ?, ?, 0, ?, ?);`, ); - for (const row of followerRows) { - const id = crypto.randomUUID(); - const result = insert.run( - id, - row.follower_id, - "user_dump_posted", - dataJson, - createdAt, - sourceKey, - ); - if ((result.changes as number) > 0) { - sendToUser(row.follower_id, { - type: "notification_created", - notification: { - id, - userId: row.follower_id, - type: "user_dump_posted", - data, - read: false, - createdAt, - }, - }); + // One transaction for the whole fan-out: a commit per follower is what made + // a popular user's dump slow to post. The live events go out inside it, but + // the commit follows in the same tick, before any client can ask back. + transaction(() => { + for (const row of followerRows) { + const id = crypto.randomUUID(); + const result = insert.run( + id, + row.follower_id, + "user_dump_posted", + dataJson, + createdAt, + sourceKey, + ); + if ((result.changes as number) > 0) { + sendToUser(row.follower_id, { + type: "notification_created", + notification: { + id, + userId: row.follower_id, + type: "user_dump_posted", + data, + read: false, + createdAt, + }, + }); + } } - } + }); } export function notifyDumpOwnerUpvote( @@ -350,12 +354,14 @@ export function notifyPlaylistFollowersNewDump( `SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`, ).all(playlistId) as { follower_id: string }[]; - for (const row of followerRows) { - createNotification( - row.follower_id, - "playlist_dump_added", - { dumpId, dumpTitle, playlistId, playlistTitle }, - `pdump:${playlistId}:${dumpId}`, - ); - } + transaction(() => { + for (const row of followerRows) { + createNotification( + row.follower_id, + "playlist_dump_added", + { dumpId, dumpTitle, playlistId, playlistTitle }, + `pdump:${playlistId}:${dumpId}`, + ); + } + }); } diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index e894a01b..691c2ad3 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -15,7 +15,9 @@ import { isDumpRow, isPlaylistRow, playlistRowToApi, + transaction, } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { broadcastPlaylistCreated, broadcastPlaylistDeleted, @@ -136,25 +138,16 @@ export function listPlaylistsByUser( requestingUserId: string | null, page: number, limit: number, -): { items: Playlist[]; total: number } { +): Page { const isOwner = requestingUserId === userId; const offset = (page - 1) * limit; - const countSql = isOwner - ? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;` - : `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`; const sql = isOwner ? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;` : `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`; - const totalRow = db.prepare(countSql).get(userId) as - | { count: number } - | undefined; - const rows = db.prepare(sql).all(userId, limit, offset); - return { - items: rows.filter(isPlaylistRow).map(playlistRowToApi), - total: totalRow?.count ?? 0, - }; + const rows = db.prepare(sql).all(userId, limit + 1, offset); + return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit); } export function updatePlaylist( @@ -382,9 +375,11 @@ export function reorderPlaylist( const update = db.prepare( `UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`, ); - for (let i = 0; i < order.length; i++) { - update.run(i, playlist.id, order[i]); - } + transaction(() => { + for (let i = 0; i < order.length; i++) { + update.run(i, playlist.id, order[i]); + } + }); broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } diff --git a/api/services/providers/bandcamp.ts b/api/services/providers/bandcamp.ts index 84a149e5..c1c4d096 100644 --- a/api/services/providers/bandcamp.ts +++ b/api/services/providers/bandcamp.ts @@ -1,6 +1,10 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; -import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; +import { + extractOgTag, + fetchWithTimeout, + readTextCapped, +} from "../rich-content-service.ts"; const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; @@ -20,10 +24,11 @@ export const bandcampProvider: RichContentProvider = { const contentType = res.headers.get("content-type") ?? ""; if (!contentType.startsWith("text/html")) { + await res.body?.cancel(); return { type: "bandcamp", siteName: "Bandcamp", url }; } - const html = await res.text(); + const html = await readTextCapped(res); return { type: "bandcamp", diff --git a/api/services/providers/generic.ts b/api/services/providers/generic.ts index faff2177..92076b6b 100644 --- a/api/services/providers/generic.ts +++ b/api/services/providers/generic.ts @@ -12,6 +12,7 @@ import { extractThemeColor, fetchWithTimeout, normalizeCssColor, + readTextCapped, } from "../rich-content-service.ts"; export const genericProvider: RichContentProvider = { @@ -26,10 +27,11 @@ export const genericProvider: RichContentProvider = { const contentType = res.headers.get("content-type") ?? ""; if (!contentType.startsWith("text/html")) { + await res.body?.cancel(); return { type: "generic", url }; } - const html = await res.text(); + const html = await readTextCapped(res); const ld = extractJsonLd(html); // If og:url is present but points to a different page (e.g. the homepage), diff --git a/api/services/providers/soundcloud.ts b/api/services/providers/soundcloud.ts index 478fb3b0..8212cd60 100644 --- a/api/services/providers/soundcloud.ts +++ b/api/services/providers/soundcloud.ts @@ -1,6 +1,10 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; -import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; +import { + extractOgTag, + fetchWithTimeout, + readTextCapped, +} from "../rich-content-service.ts"; export const soundcloudProvider: RichContentProvider = { name: "soundcloud", @@ -18,10 +22,11 @@ export const soundcloudProvider: RichContentProvider = { const contentType = res.headers.get("content-type") ?? ""; if (!contentType.startsWith("text/html")) { + await res.body?.cancel(); return { type: "soundcloud", siteName: "SoundCloud", url }; } - const html = await res.text(); + const html = await readTextCapped(res); return { type: "soundcloud", diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index e1254ef0..a02bb128 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -1,6 +1,10 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; -import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; +import { + extractOgTag, + fetchWithTimeout, + readTextCapped, +} from "../rich-content-service.ts"; function extractVideoId(url: string): string | null { try { @@ -91,6 +95,7 @@ async function fetchOEmbed( }; return { title: data.title, thumbnailUrl: data.thumbnail_url }; } + await res.body?.cancel(); } catch { // oembed failed — carry on } @@ -121,9 +126,11 @@ export const youtubeProvider: RichContentProvider = { try { const res = await fetchWithTimeout(url); if (res.ok) { - const html = await res.text(); + const html = await readTextCapped(res); title = extractOgTag(html, "title"); thumbnailUrl = extractOgTag(html, "image"); + } else { + await res.body?.cancel(); } } catch { // scrape failed — carry on with undefined values diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 847280ce..dc5bfa8d 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -70,6 +70,8 @@ async function fetchViaCurl( `${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", @@ -118,7 +120,78 @@ async function fetchViaCurl( } } -/** One request, no redirects followed. */ +/** + * 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, @@ -126,13 +199,15 @@ async function fetchOnce( ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + let res: Response; try { - return await fetch(url, { + 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; } @@ -145,9 +220,16 @@ async function fetchOnce( if (curlRes) return curlRes; throw err; - } finally { - clearTimeout(timer); } + if (!res.body) { + clearTimeout(timer); + return res; + } + return new Response(withDeadline(res.body, timer), { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); } /** @@ -606,7 +688,45 @@ export interface FetchRichContentResult { * On failure it still yields a minimal stub so a *new* dump has something * displayable, but `ok: false` lets callers with existing metadata keep it. */ -export async function tryFetchRichContent( +// Metadata fetched in the last few minutes, by URL. The create form previews a +// link and then posts it; without this the page was fetched twice, and the +// second fetch was the slow part of posting. Only successes are kept, and a +// metadata refresh (`fresh`) always goes to the page. +const RECENT_TTL_MS = 5 * 60 * 1000; +const RECENT_MAX = 200; +const recent = new Map< + string, + { at: number; result: Promise } +>(); + +/** Forget every cached result (tests). */ +export function clearRichContentCache(): void { + recent.clear(); +} + +export function tryFetchRichContent( + url: string, + { fresh = false }: { fresh?: boolean } = {}, +): Promise { + const hit = recent.get(url); + if (!fresh && hit && Date.now() - hit.at < RECENT_TTL_MS) return hit.result; + recent.delete(url); + + // Stored while in flight too, so a preview and a post racing share a fetch. + const result = fetchRichContentUncached(url); + if (recent.size >= RECENT_MAX) { + const oldest = recent.keys().next().value; + if (oldest !== undefined) recent.delete(oldest); + } + const entry = { at: Date.now(), result }; + recent.set(url, entry); + result.then(({ ok }) => { + if (!ok && recent.get(url) === entry) recent.delete(url); + }); + return result; +} + +async function fetchRichContentUncached( url: string, ): Promise { try { diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 9fe10cb3..950ad611 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -1,5 +1,6 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts"; import { db, isUserRow, userRowToApi } from "../model/db.ts"; +import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; export interface VoteResult { @@ -98,13 +99,9 @@ export function getDumpVoters( dumpId: string, page: number, limit: number, -): { items: User[]; total: number } { +): Page { const offset = (page - 1) * limit; - const totalRow = db.prepare( - `SELECT COUNT(*) as count FROM votes WHERE dump_id = ?;`, - ).get(dumpId) as { count: number } | undefined; - const rawRows = db.prepare( `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, @@ -114,7 +111,7 @@ export function getDumpVoters( INNER JOIN votes v ON v.dump_id = ? AND v.user_id = u.id ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, - ).all(dumpId, limit, offset); + ).all(dumpId, limit + 1, offset); if (!rawRows.every(isUserRow)) { throw new APIException( @@ -124,5 +121,5 @@ export function getDumpVoters( ); } - return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 }; + return pageOf(rawRows.map(userRowToApi), limit); } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 911869b4..d464ff76 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -64,18 +64,30 @@ export function getOnlineUsers(): OnlineUser[] { return Array.from(seen.values()); } +function sendRaw(socket: WebSocket, payload: string): void { + if (socket.readyState === WebSocket.OPEN) socket.send(payload); +} + function send(socket: WebSocket, data: ServerToClientMessage): void { - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(data)); + sendRaw(socket, JSON.stringify(data)); +} + +/** Sends one message to every client `include` accepts (all by default), + * serialized once rather than once per socket. */ +function broadcast( + data: ServerToClientMessage, + include?: (client: WsClient) => boolean, +): void { + let payload: string | undefined; + for (const client of clients) { + if (include && !include(client)) continue; + payload ??= JSON.stringify(data); + sendRaw(client.socket, payload); } } export function sendToUser(userId: string, data: ServerToClientMessage): void { - for (const client of clients) { - if (client.userId === userId) { - send(client.socket, data); - } - } + broadcast(data, (client) => client.userId === userId); } export function disconnectUser(userId: string): void { @@ -100,28 +112,19 @@ export function closeUserSockets(userId: string, reason: string): void { } export function broadcastPresence(): void { - const users = getOnlineUsers(); - for (const client of clients) { - send(client.socket, { type: "presence_update", users }); - } + broadcast({ type: "presence_update", users: getOnlineUsers() }); } export function broadcastNewDump(dump: Dump): void { - for (const client of clients) { - send(client.socket, { type: "dump_created", dump }); - } + broadcast({ type: "dump_created", dump }); } export function broadcastDumpUpdated(dump: Dump): void { - for (const client of clients) { - send(client.socket, { type: "dump_updated", dump }); - } + broadcast({ type: "dump_updated", dump }); } export function broadcastDumpDeleted(dumpId: string): void { - for (const client of clients) { - send(client.socket, { type: "dump_deleted", dumpId }); - } + broadcast({ type: "dump_deleted", dumpId }); } // `onlyUserId`: a private dump's count goes to that user's sockets alone. @@ -132,16 +135,10 @@ export function broadcastVoteUpdate( action: "cast" | "remove", onlyUserId?: string, ): void { - for (const client of clients) { - if (onlyUserId && client.userId !== onlyUserId) continue; - send(client.socket, { - type: "votes_update", - dumpId, - voteCount, - voterId, - action, - }); - } + broadcast( + { type: "votes_update", dumpId, voteCount, voterId, action }, + onlyUserId ? (client) => client.userId === onlyUserId : undefined, + ); } // `onlyUserId`: a like on a private dump's comment goes to that user alone. @@ -152,27 +149,20 @@ export function broadcastCommentLikeUpdate( action: "cast" | "remove", onlyUserId?: string, ): void { - for (const client of clients) { - if (onlyUserId && client.userId !== onlyUserId) continue; - send(client.socket, { - type: "comment_likes_update", - commentId, - likeCount, - likerId, - action, - }); - } + broadcast( + { type: "comment_likes_update", commentId, likeCount, likerId, action }, + onlyUserId ? (client) => client.userId === onlyUserId : undefined, + ); } function sendToPlaylistAudience( playlist: Pick, data: ServerToClientMessage, ): void { - for (const client of clients) { - if (playlist.isPublic || client.userId === playlist.userId) { - send(client.socket, data); - } - } + broadcast( + data, + (client) => playlist.isPublic || client.userId === playlist.userId, + ); } export function broadcastPlaylistCreated(playlist: Playlist): void { @@ -224,38 +214,28 @@ export function broadcastPlaylistDumpsUpdated( } export function broadcastUserUpdated(user: PublicUser): void { - for (const client of clients) { - send(client.socket, { type: "user_updated", user }); - } + broadcast({ type: "user_updated", user }); } export function broadcastCommentCreated(comment: Comment): void { - for (const client of clients) { - send(client.socket, { type: "comment_created", comment }); - } + broadcast({ type: "comment_created", comment }); } export function broadcastCommentDeleted( commentId: string, dumpId: string, ): void { - for (const client of clients) { - send(client.socket, { type: "comment_deleted", commentId, dumpId }); - } + broadcast({ type: "comment_deleted", commentId, dumpId }); } export function broadcastCommentUpdated(comment: Comment): void { - for (const client of clients) { - send(client.socket, { type: "comment_updated", comment }); - } + broadcast({ type: "comment_updated", comment }); } // Global chat room for members: history is behind auth, so the live stream is // too — an anonymous socket gets nothing chat-related. function sendToMembers(data: ServerToClientMessage): void { - for (const client of clients) { - if (client.userId) send(client.socket, data); - } + broadcast(data, (client) => !!client.userId); } // `nonce` is the sender's id for its pending copy; harmless to everyone else. @@ -346,6 +326,7 @@ export function handleClientPong(client: WsClient): void { // Keepalive: ping all clients every 30s, disconnect non-responsive ones const PING_INTERVAL = 30_000; +const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage); setInterval(() => { for (const client of clients) { @@ -360,6 +341,6 @@ setInterval(() => { continue; } client.pongReceived = false; - send(client.socket, { type: "ping" }); + sendRaw(client.socket, PING); } }, PING_INTERVAL); diff --git a/api/services/youtube-playlist-service.ts b/api/services/youtube-playlist-service.ts index c03f1a06..af3f3688 100644 --- a/api/services/youtube-playlist-service.ts +++ b/api/services/youtube-playlist-service.ts @@ -1,4 +1,5 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { readTextCapped } from "./rich-content-service.ts"; /** * Resolves a YouTube playlist to its tracklist, at play time. @@ -183,7 +184,7 @@ export async function resolveYouTubePlaylist( `YouTube returned ${res.status}`, ); } - html = await res.text(); + html = await readTextCapped(res); } catch (err) { if (err instanceof APIException) throw err; throw new APIException( diff --git a/api/test/categories-search.test.ts b/api/test/categories-search.test.ts index 741ee2ab..92839d80 100644 --- a/api/test/categories-search.test.ts +++ b/api/test/categories-search.test.ts @@ -139,7 +139,7 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy (await get>("/api/categories/music/dumps", { auth: owner.auth, })).data - .total, + .items.length, 2, ); assertEquals((await get("/api/categories/nope/dumps")).status, 404); diff --git a/api/test/comments.test.ts b/api/test/comments.test.ts index 0c8c3d85..6d480e23 100644 --- a/api/test/comments.test.ts +++ b/api/test/comments.test.ts @@ -227,7 +227,7 @@ test("comment likes: count, conflicts, likers list and notifications", async () const likers = await get>( `/api/comments/${commentId}/likers`, ); - assertEquals(likers.data.total, 2); + assertEquals(likers.data.items.length, 2); assertFalse("email" in likers.data.items[0]); assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1); diff --git a/api/test/dumps.test.ts b/api/test/dumps.test.ts index 38c288ac..1d3b5d11 100644 --- a/api/test/dumps.test.ts +++ b/api/test/dumps.test.ts @@ -278,20 +278,21 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only", "d4", "d3", ]); - assertEquals(page1.data.total, 5); assertEquals(page1.data.hasMore, true); + // Pages are told apart by the row past the limit, not by a count. + assertEquals(page1.data.total, undefined); const page3 = await get>("/api/dumps?limit=2&page=3"); assertEquals(page3.data.items.length, 1); assertEquals(page3.data.hasMore, false); - const mine = await get>("/api/dumps?limit=1", { + const mine = await get>("/api/dumps", { auth: owner.auth, }); assertEquals(mine.data.items[0].title, "secret"); - assertEquals(mine.data.total, 6); + assertEquals(mine.data.items.length, 6); assertEquals( (await get>("/api/dumps", { auth: other.auth })).data - .total, + .items.length, 5, ); }); @@ -341,7 +342,7 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () = `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ).run(id, voter.id, new Date().toISOString()); const res = await get>(`/api/dumps/${id}/voters`); - assertEquals(res.data.total, 1); + assertEquals(res.data.items.length, 1); assertEquals(res.data.items[0].username, voter.username); assertFalse("email" in res.data.items[0]); assertFalse("passwordHash" in res.data.items[0]); @@ -783,7 +784,7 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => { for (const sub of ["dumps", "votes"]) { const path = `/api/users/${owner.username}/${sub}`; const total = async (opts?: { auth: { Authorization: string } }) => - (await get>(path, opts)).data.total; + (await get>(path, opts)).data.items.length; assertEquals(await total(), 1, sub); assertEquals(await total({ auth: other.auth }), 1, sub); assertEquals( diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 81b885dd..1b3d07e9 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -7,6 +7,8 @@ import { db } from "../model/db.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"; export { app, db, TEST_DIR }; @@ -358,6 +360,9 @@ export function stubNetwork( dns: Record = {}, ): { calls: URL[] } & Disposable { const calls: URL[] = []; + // A new network: metadata cached from the last one no longer describes it. + clearRichContentCache(); + clearImageCache(); const fetchStub = stub( globalThis, "fetch", diff --git a/api/test/migrations.test.ts b/api/test/migrations.test.ts index 5f9547f8..810fc763 100644 --- a/api/test/migrations.test.ts +++ b/api/test/migrations.test.ts @@ -36,14 +36,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => .get() as { n: number; }; - assertEquals(applied.n, 15); + 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, - 15, + 16, ); db.close(); }); diff --git a/api/test/playlists.test.ts b/api/test/playlists.test.ts index e8e5aa45..ad01ad2b 100644 --- a/api/test/playlists.test.ts +++ b/api/test/playlists.test.ts @@ -70,9 +70,10 @@ test("private playlists are the owner's alone", async () => { ); insertPlaylist({ userId: owner.id }); const path = `/api/users/${owner.username}/playlists`; - assertEquals((await get>(path)).data.total, 1); + assertEquals((await get>(path)).data.items.length, 1); assertEquals( - (await get>(path, { auth: owner.auth })).data.total, + (await get>(path, { auth: owner.auth })).data.items + .length, 2, ); }); diff --git a/api/test/rich-content.test.ts b/api/test/rich-content.test.ts index 3c1e56b6..7493d0cc 100644 --- a/api/test/rich-content.test.ts +++ b/api/test/rich-content.test.ts @@ -1,6 +1,14 @@ // Link previews: the HTML extractors, the providers, and fetchWithTimeout's // guard on every hop. All network access is stubbed. -import { createUser, html, insertDump, stubNetwork, test } from "./helpers.ts"; +import { + createUser, + get, + html, + insertDump, + PNG, + stubNetwork, + test, +} from "./helpers.ts"; import { assert, assertEquals, @@ -23,6 +31,7 @@ import { fetchWithTimeout, isValidHttpUrl, normalizeCssColor, + readTextCapped, tryFetchRichContent, } from "../services/rich-content-service.ts"; @@ -183,6 +192,67 @@ Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => { 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") { @@ -380,3 +450,28 @@ test("self provider: our own dump, user and playlist pages come from the DB", as assertEquals(profile?.title, user.username); assertEquals(net.calls.length, 0); }); + +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/social.test.ts b/api/test/social.test.ts index 74dbd5d8..8515a3c7 100644 --- a/api/test/social.test.ts +++ b/api/test/social.test.ts @@ -152,7 +152,7 @@ test("followed-users feed: public dumps of the people I follow, newest first", a (await get>("/api/follows/feed/users?category=nope", { auth: me.auth, })).data, - { items: [], total: 0, hasMore: false }, + { items: [], hasMore: false }, ); assertEquals((await get("/api/follows/feed/users")).status, 401); }); @@ -177,7 +177,7 @@ test("followed-playlists feed: each dump once, public playlists only", async () auth: me.auth, }); assertEquals(titles(feed), ["shared"]); - assertEquals(feed.data.total, 1); + assertEquals(feed.data.items.length, 1); // A playlist gone private drops out of the feed. db.prepare(`UPDATE playlists SET is_public = 0;`).run(); @@ -185,7 +185,7 @@ test("followed-playlists feed: each dump once, public playlists only", async () (await get>("/api/follows/feed/playlists", { auth: me.auth, })).data - .total, + .items.length, 0, ); }); @@ -205,7 +205,7 @@ test("notifications: listed newest first, marked read one or all, never someone const list = await get>("/api/notifications", { auth: me.auth, }); - assertEquals(list.data.total, 2); + assertEquals(list.data.items.length, 2); assertEquals( list.data.items.map((n) => n.type), ["dump_commented", "user_followed"], diff --git a/api/test/uploads.test.ts b/api/test/uploads.test.ts index 73aae6c4..bdd3c8d3 100644 --- a/api/test/uploads.test.ts +++ b/api/test/uploads.test.ts @@ -39,9 +39,37 @@ test("GET /api/files/:id: headers for a safe inline type", async () => { assertEquals(res.headers.get("referrer-policy"), "no-referrer"); assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox"); assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); - assertEquals(res.headers.get("cache-control"), null); + // 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"]) { @@ -162,7 +190,7 @@ test("GET /api/files/:id: private files need the owner's media token", async () 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"); + 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,12 +228,18 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async 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( - res.headers.get("cache-control"), + (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}?token=${owner.mediaToken}`); + 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"), @@ -217,6 +251,78 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async 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", { diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 5104a3d8..ab8ab23c 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -42,6 +42,8 @@ export function Avatar( title={username} style={sizeStyle} className="avatar-img" + loading="lazy" + decoding="async" onError={() => setImgFailed(true)} /> ); diff --git a/src/components/ChatButton.tsx b/src/components/ChatButton.tsx index d9c2b9c9..c2a6c12f 100644 --- a/src/components/ChatButton.tsx +++ b/src/components/ChatButton.tsx @@ -1,10 +1,10 @@ import { t } from "@lingui/core/macro"; -import { useWS } from "../hooks/useWS.ts"; +import { useWSChat } from "../hooks/useWS.ts"; import { useChat } from "../hooks/useChat.ts"; /** Header chat button with an unread-message badge. Opens the app-wide chat. */ export function ChatButton() { - const { unreadChatCount } = useWS(); + const { unreadChatCount } = useWSChat(); const { openChat } = useChat(); return ( diff --git a/src/components/ChatFab.tsx b/src/components/ChatFab.tsx index d775f617..4e357e7e 100644 --- a/src/components/ChatFab.tsx +++ b/src/components/ChatFab.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useLocation } from "react-router"; import { t } from "@lingui/core/macro"; import { useAuth } from "../hooks/useAuth.ts"; -import { useWS } from "../hooks/useWS.ts"; +import { useWSChat } from "../hooks/useWS.ts"; import { useChat } from "../hooks/useChat.ts"; /** @@ -12,7 +12,7 @@ import { useChat } from "../hooks/useChat.ts"; */ export function ChatFab() { const { user } = useAuth(); - const { unreadChatCount } = useWS(); + const { unreadChatCount } = useWSChat(); const { openChat } = useChat(); const location = useLocation(); const [headerHidden, setHeaderHidden] = useState(false); diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index 6a750a2c..10465ccf 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -17,7 +17,7 @@ import { Avatar } from "./Avatar.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx"; import { Tooltip } from "./Tooltip.tsx"; import { useHeightTransition } from "../hooks/useHeightTransition.ts"; -import { useWS } from "../hooks/useWS.ts"; +import { useWS, useWSChat } from "../hooks/useWS.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { can } from "../utils/permissions.ts"; import { relativeTime } from "../utils/relativeTime.ts"; @@ -553,8 +553,8 @@ type Row = }; export function ChatModal({ onClose }: { onClose: () => void }) { + const { wsStatus, connectionEpoch } = useWS(); const { - wsStatus, chatMessages, deletedChatIds, pendingChatMessages, @@ -571,8 +571,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) { reconcileChat, setChatTyping, setChatOpen, - connectionEpoch, - } = useWS(); + } = useWSChat(); const { authFetch, user } = useAuth(); const canModerate = can(user, "chat:moderate"); const userId = user?.id; diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 23bd5477..48a59b8e 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; @@ -25,7 +26,9 @@ interface DumpCardProps { isOwner?: boolean; } -export function DumpCard( +// Memoized: a feed re-renders on every live event (a vote anywhere, a new +// dump), and a card whose props haven't changed has nothing to redraw. +export const DumpCard = memo(function DumpCard( { dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }: DumpCardProps, ) { @@ -139,4 +142,4 @@ export function DumpCard( ); -} +}); diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index bc899568..a244f574 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -4,7 +4,11 @@ import { formatBytes } from "../utils/format.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; -import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts"; +import { + PlayerContext, + type PlayerItem, + PlayerTimeContext, +} from "../contexts/PlayerContext.ts"; import { BAR_GAP, BAR_W, @@ -50,13 +54,15 @@ function filePlayerItem( } // Waveform preview for the dump detail page — routes to global player, -// reflects live play state and position from PlayerContext. +// reflects live play state and position from PlayerContext/PlayerTimeContext. function AudioFilePreview( { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, ) { const { mediaToken: token } = useAuth(); - const { current, playing, currentTime, duration, play, togglePlay, seekTo } = - useContext(PlayerContext); + const { current, playing, play, togglePlay, seekTo } = useContext( + PlayerContext, + ); + const { currentTime, duration } = useContext(PlayerTimeContext); const [peaks, setPeaks] = useState(null); const isActive = current?.kind === "file" && current.fileUrl === fileUrl; const progress = isActive && duration > 0 ? currentTime / duration : 0; @@ -159,6 +165,8 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) { src={src} alt="" className="rich-content-compact-thumbnail" + loading="lazy" + decoding="async" onError={() => setFailed(true)} /> ); @@ -186,11 +194,15 @@ export default function FilePreview( if (compact) { if (mime.startsWith("image/")) { + // The server's preview (or the custom thumbnail): a card doesn't need + // the full upload. return ( {dump.fileName} { (e.target as HTMLImageElement).style.display = "none"; }} diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx index 7fc025ec..39d8517b 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/JournalCard.tsx @@ -1,8 +1,9 @@ +import { memo } from "react"; import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; import { relativeTime } from "../utils/relativeTime.ts"; -import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; +import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useDumpCardLink } from "../hooks/useDumpCardLink.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; @@ -33,7 +34,8 @@ interface JournalCardProps { isOwner?: boolean; } -export function JournalCard( +// Memoized, like DumpCard: live events re-render the feed, not every card. +export const JournalCard = memo(function JournalCard( { dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }: JournalCardProps, ) { @@ -51,13 +53,13 @@ export function JournalCard( } // Mirrors FilePreview (the hot/new feeds) so a video shows its generated - // still here too, rather than degrading to a text card with a 🎬. + // still here too, rather than degrading to a text card with a 🎬. An image + // shows the server's scaled-down preview, not the full upload. const thumbnailUrl = dump.thumbnailMime ? dumpThumbnailUrl(dump, mediaToken) : dump.kind === "file" - ? (dump.fileMime?.startsWith("image/") - ? dumpFileUrl(dump, mediaToken) - : dump.fileMime?.startsWith("video/") + ? (dump.fileMime?.startsWith("image/") || + dump.fileMime?.startsWith("video/") ? dumpThumbnailUrl(dump, mediaToken) : null) : (dump.richContent?.thumbnailUrl ?? null); @@ -224,4 +226,4 @@ export function JournalCard( {footer} ); -} +}); diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index fb0a7b23..e6b43dbb 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -135,7 +136,9 @@ const INLINE_MARKDOWN_COMPONENTS: Components = { img: () => null, }; -export function Markdown( +// Memoized: parsing runs the whole remark pipeline, and the text rarely +// changes when the component around it re-renders. +export const Markdown = memo(function Markdown( { children, className, inline = false }: MarkdownProps, ) { return ( @@ -152,4 +155,4 @@ export function Markdown( ); -} +}); diff --git a/src/components/PlaylistCard.tsx b/src/components/PlaylistCard.tsx index b33b7b65..ff10634d 100644 --- a/src/components/PlaylistCard.tsx +++ b/src/components/PlaylistCard.tsx @@ -42,6 +42,8 @@ export function PlaylistCard( src={playlistImageUrl(playlist)} alt="" className="playlist-card-img" + loading="lazy" + decoding="async" /> ) : 📋} diff --git a/src/components/PresenceRow.tsx b/src/components/PresenceRow.tsx index 91a51b4b..a563dc98 100644 --- a/src/components/PresenceRow.tsx +++ b/src/components/PresenceRow.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Avatar } from "./Avatar.tsx"; -import { useWS } from "../hooks/useWS.ts"; +import { useWSPresence } from "../hooks/useWS.ts"; import { useRoomBeside } from "../hooks/useRoomBeside.ts"; // Past this many, the row ends in a "+N" counter rather than growing, so a @@ -24,7 +24,7 @@ const SEAT_STEP = 22; export function PresenceRow( { fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string }, ) { - const { onlineUsers } = useWS(); + const { onlineUsers } = useWSPresence(); const [el, setEl] = useState(null); const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside }); diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index ee971571..55b21f83 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -64,6 +64,7 @@ export default function Thumbnail( alt={alt} className={className} loading={loading} + decoding="async" referrerPolicy="no-referrer" onError={() => setAttempt( diff --git a/src/contexts/PlayerContext.ts b/src/contexts/PlayerContext.ts index 40f27236..d8b30d55 100644 --- a/src/contexts/PlayerContext.ts +++ b/src/contexts/PlayerContext.ts @@ -68,8 +68,6 @@ export interface PlayerContextValue { // Playback state — readable by any consumer current: PlayerItem | null; playing: boolean; - currentTime: number; - duration: number; // Queue. Every playback is a queue of at least one item, so single tracks and // albums share one code path. `current` is always `queue[queueIndex]`. @@ -115,8 +113,6 @@ export interface PlayerContextValue { export const defaultPlayerValue: PlayerContextValue = { current: null, playing: false, - currentTime: 0, - duration: 0, queue: [], queueIndex: 0, hasNext: false, @@ -143,3 +139,22 @@ export const defaultPlayerValue: PlayerContextValue = { export const PlayerContext = createContext( defaultPlayerValue, ); + +/** + * The playhead, apart from the rest: it moves several times a second while + * something plays, and only what draws a position should re-render with it — + * not every card that reads `current` or `playing`. + */ +export interface PlayerTimeValue { + currentTime: number; + duration: number; +} + +export const defaultPlayerTimeValue: PlayerTimeValue = { + currentTime: 0, + duration: 0, +}; + +export const PlayerTimeContext = createContext( + defaultPlayerTimeValue, +); diff --git a/src/contexts/PlayerProvider.tsx b/src/contexts/PlayerProvider.tsx index b6a7e2be..67ba8c22 100644 --- a/src/contexts/PlayerProvider.tsx +++ b/src/contexts/PlayerProvider.tsx @@ -3,6 +3,7 @@ import { PlayerContext, type PlayerItem, playerItemKey, + PlayerTimeContext, } from "./PlayerContext.ts"; import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts"; import { useAuth } from "../hooks/useAuth.ts"; @@ -392,8 +393,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) { const value = useMemo(() => ({ current, playing, - currentTime, - duration, queue, queueIndex, hasNext, @@ -418,8 +417,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) { }), [ current, playing, - currentTime, - duration, queue, queueIndex, hasNext, @@ -441,9 +438,16 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) { onError, ]); + const timeValue = useMemo( + () => ({ currentTime, duration }), + [currentTime, duration], + ); + return ( - {children} + + {children} + ); } diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index 0e3df223..dc1a7d4f 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { type ColorScheme, type StyleName, @@ -37,15 +37,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { localStorage.setItem("color-scheme", colorScheme); }, [colorScheme]); + const value = useMemo(() => ({ + style, + colorScheme, + setStyle: setStyleState, + setColorScheme: setColorSchemeState, + }), [style, colorScheme]); + return ( - + {children} ); diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts index 27744e80..969c0ec8 100644 --- a/src/contexts/WSContext.ts +++ b/src/contexts/WSContext.ts @@ -65,10 +65,15 @@ export interface ChatError { message: string; } +/** + * The socket's state, split three ways by how often each part changes, so a + * consumer re-renders only with what it reads: typing and presence update + * every few seconds while people are around, chat with every message, and a + * feed has no use for either. + */ export interface WSContextValue { wsStatus: "connecting" | "connected" | "disconnected"; wsErrorMessage: string | null; - onlineUsers: OnlineUser[]; voteCounts: Record; myVotes: Set; likeCounts: Record; @@ -84,6 +89,19 @@ export interface WSContextValue { lastUserEvent: UserEvent | null; unreadNotificationCount: number; lastNotification: Notification | null; + /** Increments on each WS reconnect so pages can backfill missed events. */ + connectionEpoch: number; + /** `shownCount`: the count on screen, used until a live one arrives. */ + castVote: (dumpId: string, shownCount?: number) => void; + removeVote: (dumpId: string, shownCount?: number) => void; + castCommentLike: (commentId: string, shownCount?: number) => void; + removeCommentLike: (commentId: string, shownCount?: number) => void; + injectDump: (dump: Dump) => void; + clearUnreadNotifications: () => void; +} + +/** The chat room: its live stream, unread state and what to send into it. */ +export interface WSChatContextValue { /** Live buffer of chat messages received this session (capped). */ chatMessages: ChatMessage[]; /** Ids of chat messages deleted live this session, so fetched history can @@ -100,15 +118,6 @@ export interface WSContextValue { /** Users currently composing a chat message (excludes nobody — the chatbox * filters out the current user itself). Shaped like presence for avatars. */ typingUsers: OnlineUser[]; - /** Increments on each WS reconnect so pages can backfill missed events. */ - connectionEpoch: number; - /** `shownCount`: the count on screen, used until a live one arrives. */ - castVote: (dumpId: string, shownCount?: number) => void; - removeVote: (dumpId: string, shownCount?: number) => void; - castCommentLike: (commentId: string, shownCount?: number) => void; - removeCommentLike: (commentId: string, shownCount?: number) => void; - injectDump: (dump: Dump) => void; - clearUnreadNotifications: () => void; sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; retryChatMessage: (nonce: string) => void; discardChatMessage: (nonce: string) => void; @@ -129,11 +138,15 @@ export interface WSContextValue { setChatOpen: (open: boolean) => void; } +/** Who's connected. */ +export interface WSPresenceContextValue { + onlineUsers: OnlineUser[]; +} + /** What a consumer outside any provider gets. */ export const defaultWSValue: WSContextValue = { wsStatus: "connecting", wsErrorMessage: null, - onlineUsers: [], voteCounts: {}, myVotes: new Set(), likeCounts: {}, @@ -149,13 +162,6 @@ export const defaultWSValue: WSContextValue = { lastUserEvent: null, unreadNotificationCount: 0, lastNotification: null, - chatMessages: [], - deletedChatIds: new Set(), - unreadChatCount: 0, - chatReadAt: null, - pendingChatMessages: [], - chatError: null, - typingUsers: [], connectionEpoch: 0, castVote: () => {}, removeVote: () => {}, @@ -163,6 +169,16 @@ export const defaultWSValue: WSContextValue = { removeCommentLike: () => {}, injectDump: () => {}, clearUnreadNotifications: () => {}, +}; + +export const defaultWSChatValue: WSChatContextValue = { + chatMessages: [], + deletedChatIds: new Set(), + unreadChatCount: 0, + chatReadAt: null, + pendingChatMessages: [], + chatError: null, + typingUsers: [], sendChatMessage: () => {}, retryChatMessage: () => {}, discardChatMessage: () => {}, @@ -175,4 +191,14 @@ export const defaultWSValue: WSContextValue = { setChatOpen: () => {}, }; +export const defaultWSPresenceValue: WSPresenceContextValue = { + onlineUsers: [], +}; + export const WSContext = createContext(defaultWSValue); +export const WSChatContext = createContext( + defaultWSChatValue, +); +export const WSPresenceContext = createContext( + defaultWSPresenceValue, +); diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 915d3666..55262d62 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -16,8 +16,12 @@ import { type PlaylistEvent, type UserEvent, type VoteEvent, + WSChatContext, + type WSChatContextValue, WSContext, type WSContextValue, + WSPresenceContext, + type WSPresenceContextValue, } from "./WSContext.ts"; import { type AvatarOverride, @@ -1130,7 +1134,6 @@ export function WSProvider({ children }: WSProviderProps) { const value: WSContextValue = useMemo(() => ({ wsStatus, wsErrorMessage, - onlineUsers, voteCounts, myVotes, likeCounts, @@ -1146,13 +1149,6 @@ export function WSProvider({ children }: WSProviderProps) { lastUserEvent, unreadNotificationCount, lastNotification, - chatMessages, - deletedChatIds, - unreadChatCount, - chatReadAt, - pendingChatMessages, - chatError, - typingUsers, connectionEpoch, castVote, removeVote, @@ -1160,6 +1156,41 @@ export function WSProvider({ children }: WSProviderProps) { removeCommentLike, injectDump, clearUnreadNotifications, + }), [ + wsStatus, + wsErrorMessage, + voteCounts, + myVotes, + likeCounts, + myLikes, + recentDumps, + deletedDumpIds, + lastVoteEvent, + lastLikeEvent, + lastDumpEvent, + lastPlaylistEvent, + deletedPlaylistIds, + lastCommentEvent, + lastUserEvent, + unreadNotificationCount, + lastNotification, + connectionEpoch, + castVote, + removeVote, + castCommentLike, + removeCommentLike, + injectDump, + clearUnreadNotifications, + ]); + + const chatValue: WSChatContextValue = useMemo(() => ({ + chatMessages, + deletedChatIds, + unreadChatCount, + chatReadAt, + pendingChatMessages, + chatError, + typingUsers, sendChatMessage, retryChatMessage, discardChatMessage, @@ -1171,24 +1202,6 @@ export function WSProvider({ children }: WSProviderProps) { setChatTyping, setChatOpen, }), [ - wsStatus, - wsErrorMessage, - onlineUsers, - voteCounts, - myVotes, - likeCounts, - myLikes, - recentDumps, - deletedDumpIds, - lastVoteEvent, - lastLikeEvent, - lastDumpEvent, - lastPlaylistEvent, - deletedPlaylistIds, - lastCommentEvent, - lastUserEvent, - unreadNotificationCount, - lastNotification, chatMessages, deletedChatIds, unreadChatCount, @@ -1196,13 +1209,6 @@ export function WSProvider({ children }: WSProviderProps) { pendingChatMessages, chatError, typingUsers, - connectionEpoch, - castVote, - removeVote, - castCommentLike, - removeCommentLike, - injectDump, - clearUnreadNotifications, sendChatMessage, retryChatMessage, discardChatMessage, @@ -1215,11 +1221,20 @@ export function WSProvider({ children }: WSProviderProps) { setChatOpen, ]); + const presenceValue: WSPresenceContextValue = useMemo( + () => ({ onlineUsers }), + [onlineUsers], + ); + return ( - - {children} - + + + + {children} + + + ); } diff --git a/src/hooks/useScrollSave.ts b/src/hooks/useScrollSave.ts index d368420f..000e949e 100644 --- a/src/hooks/useScrollSave.ts +++ b/src/hooks/useScrollSave.ts @@ -1,24 +1,37 @@ -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; /** - * Sets up a debounced scroll listener that calls `onSave(scrollY)` when the - * user scrolls, but only while `enabled` is true (e.g. the page is loaded). + * Calls `onSave(scrollY)` when the page is left — the component unmounts (a + * navigation within the app) or the tab goes away (`pagehide`) — as long as + * `enabled` is true (e.g. the page is loaded). + * + * Scrolling itself only notes the position: saving serializes the whole feed + * into sessionStorage, which on every pause in scrolling cost a long task. */ export function useScrollSave( enabled: boolean, onSave: (scrollY: number) => void, ) { + const onSaveRef = useRef(onSave); + useLayoutEffect(() => { + onSaveRef.current = onSave; + }); + useEffect(() => { if (!enabled) return; - let timer: ReturnType; + // Tracked as it happens: by the time the unmount runs, the next page may + // already have scrolled the window back to the top. + let y = globalThis.scrollY; const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => onSave(globalThis.scrollY), 100); + y = globalThis.scrollY; }; + const save = () => onSaveRef.current(y); globalThis.addEventListener("scroll", onScroll, { passive: true }); + globalThis.addEventListener("pagehide", save); return () => { globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); + globalThis.removeEventListener("pagehide", save); + save(); }; - }, [enabled, onSave]); + }, [enabled]); } diff --git a/src/hooks/useWS.ts b/src/hooks/useWS.ts index 8403ddd1..5222e3b9 100644 --- a/src/hooks/useWS.ts +++ b/src/hooks/useWS.ts @@ -1,6 +1,21 @@ import { useContext } from "react"; -import { WSContext } from "../contexts/WSContext.ts"; +import { + WSChatContext, + WSContext, + WSPresenceContext, +} from "../contexts/WSContext.ts"; +/** Votes, likes, live dumps, playlists, comments and notifications. */ export function useWS() { return useContext(WSContext); } + +/** The chat room. Re-renders with every message and typing change. */ +export function useWSChat() { + return useContext(WSChatContext); +} + +/** Who's online. */ +export function useWSPresence() { + return useContext(WSPresenceContext); +} diff --git a/src/locales/en.po b/src/locales/en.po index 5effc78d..5c855aae 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -18,13 +18,13 @@ msgid "[deleted]" msgstr "[deleted]" #. placeholder {0}: dump.commentCount -#: src/components/DumpCard.tsx:109 -#: src/components/JournalCard.tsx:114 +#: src/components/DumpCard.tsx:112 +#: src/components/JournalCard.tsx:116 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:85 +#: src/components/PlaylistCard.tsx:87 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" @@ -35,12 +35,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/ChatModal.tsx:1079 +#: src/components/ChatModal.tsx:1078 msgid "{0} and {1} are typing…" msgstr "{0} and {1} are typing…" #. placeholder {0}: typingNames[0] -#: src/components/ChatModal.tsx:1077 +#: src/components/ChatModal.tsx:1076 msgid "{0} is typing…" msgstr "{0} is typing…" @@ -59,7 +59,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/ChatModal.tsx:1208 +#: src/components/ChatModal.tsx:1207 msgid "{unseenCount} new messages" msgstr "{unseenCount} new messages" @@ -143,7 +143,7 @@ msgstr "<0>{0} upvoted <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" -#: src/components/ChatModal.tsx:1207 +#: src/components/ChatModal.tsx:1206 msgid "1 new message" msgstr "1 new message" @@ -220,8 +220,8 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/contexts/WSProvider.tsx:328 -#: src/contexts/WSProvider.tsx:711 +#: src/contexts/WSProvider.tsx:332 +#: src/contexts/WSProvider.tsx:715 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." @@ -244,8 +244,8 @@ msgstr "Cancel" msgid "Cancel removal" msgstr "Cancel removal" +#: src/components/ChatModal.tsx:1267 #: src/components/ChatModal.tsx:1268 -#: src/components/ChatModal.tsx:1269 msgid "Cancel reply" msgstr "Cancel reply" @@ -282,7 +282,7 @@ msgstr "Change skin tone" #: src/components/ChatButton.tsx:17 #: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:50 -#: src/components/ChatModal.tsx:1168 +#: src/components/ChatModal.tsx:1167 msgid "Chat" msgstr "Chat" @@ -329,7 +329,7 @@ msgstr "Color scheme" msgid "Confirm new password" msgstr "Confirm new password" -#: src/contexts/WSProvider.tsx:704 +#: src/contexts/WSProvider.tsx:708 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" @@ -362,7 +362,7 @@ msgstr "Could not save" msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." -#: src/components/ChatModal.tsx:1094 +#: src/components/ChatModal.tsx:1093 msgid "Couldn't load the chat." msgstr "Couldn't load the chat." @@ -427,7 +427,7 @@ msgstr "Delete dump" msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/PlaylistCard.tsx:108 +#: src/components/PlaylistCard.tsx:110 #: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" @@ -463,12 +463,12 @@ msgstr "Description (optional)" msgid "Discard" msgstr "Discard" -#: src/components/ChatModal.tsx:1172 +#: src/components/ChatModal.tsx:1171 msgid "Disconnected — reconnecting…" msgstr "Disconnected — reconnecting…" +#: src/components/ChatModal.tsx:1243 #: src/components/ChatModal.tsx:1244 -#: src/components/ChatModal.tsx:1245 msgid "Dismiss" msgstr "Dismiss" @@ -477,7 +477,7 @@ msgid "Done" msgstr "Done" #. placeholder {0}: dump.fileName -#: src/components/FilePreview.tsx:317 +#: src/components/FilePreview.tsx:329 msgid "Download {0}" msgstr "Download {0}" @@ -804,7 +804,7 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/WSProvider.tsx:710 +#: src/contexts/WSProvider.tsx:714 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" @@ -818,7 +818,7 @@ msgstr "Live updates unavailable." msgid "Load more" msgstr "Load more" -#: src/components/ChatModal.tsx:1193 +#: src/components/ChatModal.tsx:1192 msgid "Load older messages" msgstr "Load older messages" @@ -827,7 +827,7 @@ msgstr "Load older messages" msgid "Loading dump…" msgstr "Loading dump…" -#: src/components/ChatModal.tsx:1086 +#: src/components/ChatModal.tsx:1085 msgid "Loading messages…" msgstr "Loading messages…" @@ -852,7 +852,7 @@ msgid "Loading profile…" msgstr "Loading profile…" #: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:1192 +#: src/components/ChatModal.tsx:1191 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:669 #: src/components/UserListPopover.tsx:198 @@ -926,7 +926,7 @@ msgstr "Name" msgid "new" msgstr "new" -#: src/components/ChatModal.tsx:1121 +#: src/components/ChatModal.tsx:1120 #: src/components/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" @@ -985,7 +985,7 @@ msgstr "No followed playlists yet." msgid "No invitees yet." msgstr "No invitees yet." -#: src/components/ChatModal.tsx:1105 +#: src/components/ChatModal.tsx:1104 msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" @@ -1003,7 +1003,7 @@ msgstr "No playlists match \"{q}\"." msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/WSProvider.tsx:944 +#: src/contexts/WSProvider.tsx:948 msgid "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent" @@ -1090,8 +1090,8 @@ msgstr "Passwords do not match" msgid "Paste a link…" msgstr "Paste a link…" -#: src/components/FilePreview.tsx:108 -#: src/components/FilePreview.tsx:269 +#: src/components/FilePreview.tsx:114 +#: src/components/FilePreview.tsx:281 #: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 msgid "Pause" @@ -1101,10 +1101,10 @@ msgstr "Pause" msgid "Picked up where you left off." msgstr "Picked up where you left off." -#: src/components/FilePreview.tsx:108 -#: src/components/FilePreview.tsx:213 -#: src/components/FilePreview.tsx:234 -#: src/components/FilePreview.tsx:269 +#: src/components/FilePreview.tsx:114 +#: src/components/FilePreview.tsx:225 +#: src/components/FilePreview.tsx:246 +#: src/components/FilePreview.tsx:281 #: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 #: src/components/RichContentCard.tsx:129 @@ -1148,9 +1148,9 @@ msgstr "Posting…" msgid "Previous track" msgstr "Previous track" -#: src/components/DumpCard.tsx:118 -#: src/components/JournalCard.tsx:123 -#: src/components/PlaylistCard.tsx:72 +#: src/components/DumpCard.tsx:121 +#: src/components/JournalCard.tsx:125 +#: src/components/PlaylistCard.tsx:74 #: src/components/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 #: src/pages/PlaylistDetail.tsx:681 @@ -1162,7 +1162,7 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/PlaylistCard.tsx:71 +#: src/components/PlaylistCard.tsx:73 #: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" @@ -1228,7 +1228,7 @@ msgstr "Replace file" msgid "Reply" msgstr "Reply" -#: src/components/ChatModal.tsx:1256 +#: src/components/ChatModal.tsx:1255 msgid "Replying to" msgstr "Replying to" @@ -1315,7 +1315,7 @@ msgstr "Searching…" msgid "Seek" msgstr "Seek" -#: src/components/ChatModal.tsx:1296 +#: src/components/ChatModal.tsx:1295 msgid "Send" msgstr "Send" @@ -1341,7 +1341,7 @@ msgstr "Set new password" msgid "Settings" msgstr "Settings" -#: src/components/ChatModal.tsx:1080 +#: src/components/ChatModal.tsx:1079 msgid "Several people are typing…" msgstr "Several people are typing…" @@ -1359,7 +1359,7 @@ msgstr "Some images couldn't be uploaded." #: src/components/form/FormError.tsx:16 #: src/components/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:664 +#: src/contexts/WSProvider.tsx:668 #: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" msgstr "Something went wrong" @@ -1436,11 +1436,11 @@ msgstr "Today" msgid "Track {0}" msgstr "Track {0}" -#: src/components/ChatModal.tsx:1097 +#: src/components/ChatModal.tsx:1096 msgid "Try again" msgstr "Try again" -#: src/components/ChatModal.tsx:1281 +#: src/components/ChatModal.tsx:1280 msgid "Type a message…" msgstr "Type a message…" @@ -1579,7 +1579,7 @@ msgstr "You can post it anyway." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." -#: src/contexts/WSProvider.tsx:925 +#: src/contexts/WSProvider.tsx:929 msgid "You're offline — not sent" msgstr "You're offline — not sent" diff --git a/src/locales/fr.po b/src/locales/fr.po index 07e5f6c2..16b8d979 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -18,13 +18,13 @@ msgid "[deleted]" msgstr "[supprimé]" #. placeholder {0}: dump.commentCount -#: src/components/DumpCard.tsx:109 -#: src/components/JournalCard.tsx:114 +#: src/components/DumpCard.tsx:112 +#: src/components/JournalCard.tsx:116 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:85 +#: src/components/PlaylistCard.tsx:87 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" @@ -35,12 +35,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/ChatModal.tsx:1079 +#: src/components/ChatModal.tsx:1078 msgid "{0} and {1} are typing…" msgstr "{0} et {1} sont en train d'écrire…" #. placeholder {0}: typingNames[0] -#: src/components/ChatModal.tsx:1077 +#: src/components/ChatModal.tsx:1076 msgid "{0} is typing…" msgstr "{0} est en train d'écrire…" @@ -59,7 +59,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/ChatModal.tsx:1208 +#: src/components/ChatModal.tsx:1207 msgid "{unseenCount} new messages" msgstr "{unseenCount} nouveaux messages" @@ -143,7 +143,7 @@ msgstr "<0>{0} a voté pour <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" -#: src/components/ChatModal.tsx:1207 +#: src/components/ChatModal.tsx:1206 msgid "1 new message" msgstr "1 nouveau message" @@ -220,8 +220,8 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/contexts/WSProvider.tsx:328 -#: src/contexts/WSProvider.tsx:711 +#: src/contexts/WSProvider.tsx:332 +#: src/contexts/WSProvider.tsx:715 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." @@ -244,8 +244,8 @@ msgstr "Annuler" msgid "Cancel removal" msgstr "Annuler la suppression" +#: src/components/ChatModal.tsx:1267 #: src/components/ChatModal.tsx:1268 -#: src/components/ChatModal.tsx:1269 msgid "Cancel reply" msgstr "Annuler la réponse" @@ -282,7 +282,7 @@ msgstr "Changer la couleur de peau" #: src/components/ChatButton.tsx:17 #: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:50 -#: src/components/ChatModal.tsx:1168 +#: src/components/ChatModal.tsx:1167 msgid "Chat" msgstr "Tribune" @@ -329,7 +329,7 @@ msgstr "Thème de couleur" msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" -#: src/contexts/WSProvider.tsx:704 +#: src/contexts/WSProvider.tsx:708 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" @@ -362,7 +362,7 @@ msgstr "Sauvegarde impossible" msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." -#: src/components/ChatModal.tsx:1094 +#: src/components/ChatModal.tsx:1093 msgid "Couldn't load the chat." msgstr "Impossible de charger le chat." @@ -427,7 +427,7 @@ msgstr "Supprimer la reco" msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/PlaylistCard.tsx:108 +#: src/components/PlaylistCard.tsx:110 #: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" @@ -463,12 +463,12 @@ msgstr "Description (facultatif)" msgid "Discard" msgstr "Abandonner" -#: src/components/ChatModal.tsx:1172 +#: src/components/ChatModal.tsx:1171 msgid "Disconnected — reconnecting…" msgstr "Déconnecté — reconnexion…" +#: src/components/ChatModal.tsx:1243 #: src/components/ChatModal.tsx:1244 -#: src/components/ChatModal.tsx:1245 msgid "Dismiss" msgstr "Fermer" @@ -477,7 +477,7 @@ msgid "Done" msgstr "Terminé" #. placeholder {0}: dump.fileName -#: src/components/FilePreview.tsx:317 +#: src/components/FilePreview.tsx:329 msgid "Download {0}" msgstr "Télécharger {0}" @@ -804,7 +804,7 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/WSProvider.tsx:710 +#: src/contexts/WSProvider.tsx:714 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" @@ -818,7 +818,7 @@ msgstr "Mises à jour en direct indisponibles." msgid "Load more" msgstr "Charger plus" -#: src/components/ChatModal.tsx:1193 +#: src/components/ChatModal.tsx:1192 msgid "Load older messages" msgstr "Charger les messages plus anciens" @@ -827,7 +827,7 @@ msgstr "Charger les messages plus anciens" msgid "Loading dump…" msgstr "Chargement de la reco…" -#: src/components/ChatModal.tsx:1086 +#: src/components/ChatModal.tsx:1085 msgid "Loading messages…" msgstr "Chargement des messages…" @@ -852,7 +852,7 @@ msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:1192 +#: src/components/ChatModal.tsx:1191 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:669 #: src/components/UserListPopover.tsx:198 @@ -926,7 +926,7 @@ msgstr "Nom" msgid "new" msgstr "nouveau" -#: src/components/ChatModal.tsx:1121 +#: src/components/ChatModal.tsx:1120 #: src/components/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" @@ -985,7 +985,7 @@ msgstr "Pas encore de collections suivies." msgid "No invitees yet." msgstr "Aucun invité pour le moment." -#: src/components/ChatModal.tsx:1105 +#: src/components/ChatModal.tsx:1104 msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" @@ -1003,7 +1003,7 @@ msgstr "Aucune collection ne correspond à « {q} »." msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/WSProvider.tsx:944 +#: src/contexts/WSProvider.tsx:948 msgid "No reply from the server — this message may not have been sent" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" @@ -1090,8 +1090,8 @@ msgstr "Les mots de passe ne correspondent pas" msgid "Paste a link…" msgstr "Collez un lien…" -#: src/components/FilePreview.tsx:108 -#: src/components/FilePreview.tsx:269 +#: src/components/FilePreview.tsx:114 +#: src/components/FilePreview.tsx:281 #: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 msgid "Pause" @@ -1101,10 +1101,10 @@ msgstr "Pause" msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." -#: src/components/FilePreview.tsx:108 -#: src/components/FilePreview.tsx:213 -#: src/components/FilePreview.tsx:234 -#: src/components/FilePreview.tsx:269 +#: src/components/FilePreview.tsx:114 +#: src/components/FilePreview.tsx:225 +#: src/components/FilePreview.tsx:246 +#: src/components/FilePreview.tsx:281 #: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 #: src/components/RichContentCard.tsx:129 @@ -1148,9 +1148,9 @@ msgstr "Publication…" msgid "Previous track" msgstr "Piste précédente" -#: src/components/DumpCard.tsx:118 -#: src/components/JournalCard.tsx:123 -#: src/components/PlaylistCard.tsx:72 +#: src/components/DumpCard.tsx:121 +#: src/components/JournalCard.tsx:125 +#: src/components/PlaylistCard.tsx:74 #: src/components/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 #: src/pages/PlaylistDetail.tsx:681 @@ -1162,7 +1162,7 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/PlaylistCard.tsx:71 +#: src/components/PlaylistCard.tsx:73 #: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" @@ -1228,7 +1228,7 @@ msgstr "Remplacer le fichier" msgid "Reply" msgstr "Répondre" -#: src/components/ChatModal.tsx:1256 +#: src/components/ChatModal.tsx:1255 msgid "Replying to" msgstr "En réponse à" @@ -1315,7 +1315,7 @@ msgstr "Recherche…" msgid "Seek" msgstr "Position" -#: src/components/ChatModal.tsx:1296 +#: src/components/ChatModal.tsx:1295 msgid "Send" msgstr "Envoyer" @@ -1341,7 +1341,7 @@ msgstr "Définir un nouveau mot de passe" msgid "Settings" msgstr "Paramètres" -#: src/components/ChatModal.tsx:1080 +#: src/components/ChatModal.tsx:1079 msgid "Several people are typing…" msgstr "Plusieurs personnes écrivent…" @@ -1359,7 +1359,7 @@ msgstr "Certaines images n'ont pas pu être envoyées." #: src/components/form/FormError.tsx:16 #: src/components/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:664 +#: src/contexts/WSProvider.tsx:668 #: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" msgstr "Une erreur est survenue" @@ -1436,11 +1436,11 @@ msgstr "Aujourd'hui" msgid "Track {0}" msgstr "Piste {0}" -#: src/components/ChatModal.tsx:1097 +#: src/components/ChatModal.tsx:1096 msgid "Try again" msgstr "Réessayer" -#: src/components/ChatModal.tsx:1281 +#: src/components/ChatModal.tsx:1280 msgid "Type a message…" msgstr "Écrivez un message…" @@ -1579,7 +1579,7 @@ msgstr "Vous pouvez la publier quand même." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." -#: src/contexts/WSProvider.tsx:925 +#: src/contexts/WSProvider.tsx:929 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" diff --git a/src/model.ts b/src/model.ts index a05934d9..304c78ea 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,7 +1,8 @@ export interface PaginatedData { items: T[]; - total: number; hasMore: boolean; + /** The whole list's size — only where it's shown (search results). */ + total?: number; } /** diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index f0dcbcb2..eb2b54ff 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -489,13 +489,13 @@ export function UserPublicProfile() { const votesData: PaginatedData = votesBody.success ? votesBody.data - : { items: [], total: 0, hasMore: false }; + : { items: [], hasMore: false }; const playlistsData: PaginatedData = playlistsBody.success ? playlistsBody.data - : { items: [], total: 0, hasMore: false }; + : { items: [], hasMore: false }; const dumpsData: PaginatedData = dumpsBody.success ? dumpsBody.data - : { items: [], total: 0, hasMore: false }; + : { items: [], hasMore: false }; const profileUser = deserializePublicUser(userBody.data); const voteItems = votesData.items.map(deserializeDump); diff --git a/src/test/components/media.test.tsx b/src/test/components/media.test.tsx index 95276f11..dc26386a 100644 --- a/src/test/components/media.test.tsx +++ b/src/test/components/media.test.tsx @@ -5,6 +5,11 @@ import { MediaPlayer } from "../../components/MediaPlayer.tsx"; import FilePreview from "../../components/FilePreview.tsx"; import { makeDump, makeSession } from "../fixtures.ts"; import { renderWithContexts } from "../render.tsx"; +import { + dumpFileUrl, + dumpFileVersion, + dumpThumbnailUrl, +} from "../../utils/urls.ts"; function media(container: HTMLElement) { return container.querySelector("audio, video")!; @@ -221,13 +226,24 @@ describe("FilePreview", () => { }); test("images show inline, with the media token", () => { - renderWithContexts(, { session }); + const d = file("image/png"); + renderWithContexts(, { session }); expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute( "src", - `http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`, + `http://localhost:8000/api/files/f1?v=${ + dumpFileVersion(d) + }&token=${session.mediaToken}`, ); }); + test("a compact image shows the server's preview, not the upload", () => { + const d = file("image/png"); + renderWithContexts(, { session }); + const img = screen.getByRole("img", { name: "thing" }); + expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken)); + expect(img).toHaveAttribute("loading", "lazy"); + }); + test("other files are a download link", () => { renderWithContexts(); const link = screen.getByRole("link", { name: /Download thing/ }); @@ -253,12 +269,14 @@ describe("FilePreview", () => { test("compact video claims its generated still as artwork", async () => { const play = vi.fn(); - renderWithContexts(, { + const d = file("video/mp4"); + renderWithContexts(, { player: { play }, }); await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play.mock.calls[0][0].artworkUrl).toBe( - "http://localhost:8000/api/thumbnails/f1", + expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d)); + expect(play.mock.calls[0][0].artworkUrl).toMatch( + /^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/, ); }); @@ -271,7 +289,7 @@ describe("FilePreview", () => { playing: true, current: { kind: "file", - fileUrl: "http://localhost:8000/api/files/f1?v=2048", + fileUrl: dumpFileUrl(d), mimeType: "video/mp4", }, }, diff --git a/src/test/components/player.test.tsx b/src/test/components/player.test.tsx index c4df62c8..ba7aa733 100644 --- a/src/test/components/player.test.tsx +++ b/src/test/components/player.test.tsx @@ -13,6 +13,7 @@ import { import { makeDump, makePlaylist } from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; import { markPlaylistVisited } from "../../utils/visited.ts"; +import { dumpThumbnailUrl } from "../../utils/urls.ts"; let player!: React.ContextType; function Probe() { @@ -272,22 +273,19 @@ describe("JournalCard", () => { }); test("a video file shows its generated still", () => { + const dump = makeDump({ + id: "v", + kind: "file", + fileMime: "video/mp4", + url: undefined, + }); const { container } = renderWithContexts(
    - +
, ); expect(container.querySelector("img")!.getAttribute("src")).toBe( - "http://localhost:8000/api/thumbnails/v", + dumpThumbnailUrl(dump), ); }); }); diff --git a/src/test/contexts/PlayerProvider.test.tsx b/src/test/contexts/PlayerProvider.test.tsx index d0ed4db9..2880bb4b 100644 --- a/src/test/contexts/PlayerProvider.test.tsx +++ b/src/test/contexts/PlayerProvider.test.tsx @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { type ReactNode, useContext, useState } from "react"; +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import { memo, type ReactNode, useContext, useState } from "react"; import { AuthContext } from "../../contexts/AuthContext.ts"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; import { PlayerContext, type PlayerItem, + PlayerTimeContext, } from "../../contexts/PlayerContext.ts"; import type { AuthResponse } from "../../model.ts"; import { STREAM_STALE_MS } from "../../utils/streamSources.ts"; @@ -64,7 +65,10 @@ function wrapperFor(initial: AuthResponse | null) { } function setup(session: AuthResponse | null = null) { - const hook = renderHook(() => useContext(PlayerContext), { + const hook = renderHook(() => ({ + ...useContext(PlayerContext), + ...useContext(PlayerTimeContext), + }), { wrapper: wrapperFor(session), }); return { hook, p: () => hook.result.current }; @@ -395,3 +399,41 @@ describe("expiring streams", () => { beforeEach(() => { setSession = () => {}; }); + +describe("the playhead", () => { + test("a time update re-renders what reads the time, not the rest", () => { + let controls = 0; + let clock = 0; + // Two consumers under one provider: one reads the controls, one the time. + const Probe = () => { + useContext(PlayerContext); + controls++; + return null; + }; + const Clock = () => { + useContext(PlayerTimeContext); + clock++; + return null; + }; + const Wrapper = wrapperFor(null); + let onTimeUpdate!: (t: number, d: number) => void; + const Grab = () => { + onTimeUpdate = useContext(PlayerContext).onTimeUpdate; + return null; + }; + const MemoProbe = memo(Probe); + const MemoClock = memo(Clock); + render( + + + + + , + ); + const before = { controls, clock }; + act(() => onTimeUpdate(5, 100)); + act(() => onTimeUpdate(6, 100)); + expect(controls).toBe(before.controls); + expect(clock).toBe(before.clock + 2); + }); +}); diff --git a/src/test/contexts/WSProvider.test.tsx b/src/test/contexts/WSProvider.test.tsx index 4e842ce8..22afca30 100644 --- a/src/test/contexts/WSProvider.test.tsx +++ b/src/test/contexts/WSProvider.test.tsx @@ -5,7 +5,7 @@ import { useContext } from "react"; import { AuthProvider } from "../../contexts/AuthProvider.tsx"; import { WSProvider } from "../../contexts/WSProvider.tsx"; import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; -import { useWS } from "../../hooks/useWS.ts"; +import { useWS, useWSChat, useWSPresence } from "../../hooks/useWS.ts"; import { useAuth } from "../../hooks/useAuth.ts"; import { FakeWebSocket } from "../ws.ts"; import { defaultRoutes } from "../render.tsx"; @@ -36,7 +36,7 @@ function setup({ signedIn = true } = {}) { defaultRoutes({ session: signedIn ? me : null }); const hook = renderHook( () => ({ - ws: useWS(), + ws: { ...useWS(), ...useWSChat(), ...useWSPresence() }, auth: useAuth(), avatars: useContext(AvatarOverrideContext), }), @@ -805,3 +805,33 @@ describe("chat", () => { }); }); }); + +describe("split contexts", () => { + test("chat, typing and presence leave the feed's context alone", () => { + storeSession(me); + defaultRoutes({ session: me }); + const feeds: unknown[] = []; + const chats: unknown[] = []; + renderHook(() => { + feeds.push(useWS()); + chats.push(useWSChat()); + }, { wrapper }); + const socket = FakeWebSocket.last; + socket.welcome(); + const feed = feeds.at(-1); + const chat = chats.at(-1); + socket.receive({ + type: "chat_typing_update", + users: [{ userId: "x", username: "x", hasAvatar: false }], + }); + socket.receive({ + type: "presence_update", + users: [{ userId: "x", username: "x", hasAvatar: false }], + }); + socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); + // The feed's value is the same object: its consumers had no reason to + // re-render. The chat's moved on. + expect(feeds.at(-1)).toBe(feed); + expect(chats.at(-1)).not.toBe(chat); + }); +}); diff --git a/src/test/hooks/misc.test.tsx b/src/test/hooks/misc.test.tsx index 94b75d92..82945847 100644 --- a/src/test/hooks/misc.test.tsx +++ b/src/test/hooks/misc.test.tsx @@ -379,22 +379,51 @@ describe("useScrollLock", () => { }); describe("useScrollSave", () => { - test("debounces scroll positions while enabled", () => { - vi.useFakeTimers(); + const scrollTo = (y: number) => { + Object.defineProperty(globalThis, "scrollY", { + value: y, + configurable: true, + }); + globalThis.dispatchEvent(new Event("scroll")); + }; + + test("saves the last position when the page is left, not while scrolling", () => { const onSave = vi.fn(); - const { rerender, unmount } = renderHook( - ({ on }) => useScrollSave(on, onSave), - { initialProps: { on: true } }, - ); - globalThis.dispatchEvent(new Event("scroll")); - globalThis.dispatchEvent(new Event("scroll")); - vi.advanceTimersByTime(100); - expect(onSave).toHaveBeenCalledTimes(1); - rerender({ on: false }); - globalThis.dispatchEvent(new Event("scroll")); - vi.advanceTimersByTime(100); - expect(onSave).toHaveBeenCalledTimes(1); + const { unmount } = renderHook(() => useScrollSave(true, onSave)); + scrollTo(100); + scrollTo(250); + expect(onSave).not.toHaveBeenCalled(); + // The next page has already scrolled back up by the time this unmounts. + Object.defineProperty(globalThis, "scrollY", { + value: 0, + configurable: true, + }); unmount(); + expect(onSave).toHaveBeenCalledExactlyOnceWith(250); + }); + + test("saves on pagehide, with the latest callback", () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender, unmount } = renderHook( + ({ cb }) => useScrollSave(true, cb), + { initialProps: { cb: first } }, + ); + scrollTo(40); + rerender({ cb: second }); + globalThis.dispatchEvent(new Event("pagehide")); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledExactlyOnceWith(40); + unmount(); + }); + + test("nothing is tracked or saved while disabled", () => { + const onSave = vi.fn(); + const { unmount } = renderHook(() => useScrollSave(false, onSave)); + scrollTo(10); + globalThis.dispatchEvent(new Event("pagehide")); + unmount(); + expect(onSave).not.toHaveBeenCalled(); }); }); diff --git a/src/test/render.tsx b/src/test/render.tsx index 36799b54..224ad3c0 100644 --- a/src/test/render.tsx +++ b/src/test/render.tsx @@ -8,9 +8,15 @@ import type { AuthResponse, Category } from "../model.ts"; import { AuthContext } from "../contexts/AuthContext.ts"; import { AuthProvider } from "../contexts/AuthProvider.tsx"; import { + defaultWSChatValue, + defaultWSPresenceValue, defaultWSValue, + WSChatContext, + type WSChatContextValue, WSContext, type WSContextValue, + WSPresenceContext, + type WSPresenceContextValue, } from "../contexts/WSContext.ts"; import { WSProvider } from "../contexts/WSProvider.tsx"; import { @@ -25,9 +31,12 @@ import { } from "../contexts/CategoriesContext.ts"; import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx"; import { + defaultPlayerTimeValue, defaultPlayerValue, PlayerContext, type PlayerContextValue, + PlayerTimeContext, + type PlayerTimeValue, } from "../contexts/PlayerContext.ts"; import { PlayerProvider } from "../contexts/PlayerProvider.tsx"; import { ThemeProvider } from "../contexts/ThemeProvider.tsx"; @@ -84,10 +93,11 @@ function Router( export interface StubOptions extends RouterOptions { session?: AuthResponse | null; - ws?: Partial; + /** Any of the socket's three contexts' fields; each goes to its own. */ + ws?: Partial; follows?: Partial; categories?: Category[] | Partial; - player?: Partial; + player?: Partial; } function StubAuth( @@ -101,6 +111,17 @@ function StubAuth( ); } +/** `defaults`, with whichever of its keys `overrides` sets. */ +function pick(defaults: T, overrides: object = {}): T { + const out = { ...defaults }; + for (const key of Object.keys(defaults) as (keyof T)[]) { + if (key in overrides) { + out[key] = (overrides as T)[key]; + } + } + return out; +} + function categoriesValue( c: StubOptions["categories"], ): CategoriesContextValue { @@ -130,17 +151,36 @@ export function renderWithContexts( - - - - {children} - - - + + + + + + + {children} + + + + + + diff --git a/src/test/utils/misc.test.ts b/src/test/utils/misc.test.ts index e545a7a3..f6b3fa86 100644 --- a/src/test/utils/misc.test.ts +++ b/src/test/utils/misc.test.ts @@ -141,12 +141,17 @@ describe("thumbnail tint", () => { }); describe("urls", () => { - test("dumpFileUrl versions by size and carries the token", () => { - expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe( - `${API_URL}/api/files/d1?v=42`, + test("dumpFileUrl versions by size and last change, and carries the token", () => { + const createdAt = new Date(1000); + const updatedAt = new Date(2000); + expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe( + `${API_URL}/api/files/d1?v=42-1000`, ); + // An edit (a replaced file among them) moves the version on. + expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt })) + .toBe(`${API_URL}/api/files/d1?v=42-2000`); expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe( - `${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`, + `${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`, ); }); test("dumpThumbnailUrl", () => { @@ -154,6 +159,9 @@ describe("urls", () => { expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe( `${API_URL}/api/thumbnails/d1?token=tk`, ); + expect( + dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"), + ).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`); }); test("normalizeUrl adds a scheme when there's none", () => { expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x"); diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 7709cc57..4cc7ec0c 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,5 +1,23 @@ import { API_URL } from "../config/api.ts"; +type VersionedDump = { + id: string; + fileSize?: number; + createdAt?: Date; + updatedAt?: Date; +}; + +/** + * A file dump's version, as the server computes it (dumpFileVersion in + * api/services/dump-service.ts): it changes whenever the file can have. A URL + * carrying the current one is cached by the browser for good; any other is + * revalidated. + */ +export function dumpFileVersion(dump: Omit): string { + const at = dump.updatedAt ?? dump.createdAt; + return `${dump.fileSize ?? 0}-${at ? at.getTime() : 0}`; +} + /** * Direct URL to a dump's uploaded file. The viewer's token is passed as a query * param — media elements (`