v3: a performance pass over both halves — indexed queries, streamed files, cached previews, and feeds that stop re-rendering on every tick — a review turned up whole-file reads, unindexed scans and render storms; each fix comes with a test. Database: migration 0016 adds the indexes the hot queries lacked — dumps by created_at, (user_id, created_at) and slug, votes by (user_id, created_at), playlists by slug, users by lower(username) for @mentions, and the child side of every cascading foreign key (comment replies, a user's comments, follows both ways, chat messages, invites, reset tokens); the feeds, dump and playlist pages, upvotes and follower fan-out all plan as index searches now, with no temp-table sort. SQLite runs in WAL with synchronous=NORMAL and a busy timeout, and a transaction() helper wraps the multi-row writes (a dump and its categories, category sets, backlinks, attachment links, playlist reorders, follower notifications) so they commit once instead of once a row. Lists no longer run a COUNT(*) per page: they fetch limit + 1 rows and hasMore comes from the extra one; total stays only on search, the one place it's shown. Files: /api/files read the whole upload (up to 50 MB) for every range request a player makes while seeking; api/lib/serve-file.ts streams the requested bytes from disk, with an ETag and 304s, and serves avatars, attachments, covers and thumbnails too. File and thumbnail URLs carry the file's version (size and last change), so the current one is cached for a year and anything else revalidates — a same-size replacement used to stay stale, and a video's still was cached immutable forever under a URL that never changed. Feeds showed image dumps at full size: /api/thumbnails now makes a 960px preview with ffmpeg (GIFs and unreadable images go out as they are), one run per output however many cards ask at once, and ffmpeg is probed once per process instead of spawned per request. Uploads stream to disk; replacing a file writes beside it and renames over, a hard link keeping the old one for rollback instead of holding both in memory; generated stills and previews go when the file is replaced or the dump deleted, and a custom thumbnail with its dump. Outbound: fetchWithTimeout's deadline now covers the body — a server that answered at once and then dripped could hold a create open indefinitely — and pages are read to 4 MB at most; metadata is cached five minutes so the create form's preview and the post that follows share one fetch (refresh-metadata still goes to the page); proxied images sit in a 32 MB LRU. Broadcasts serialize once rather than per socket, and index.html is kept in memory until the file changes. Frontend: currentTime lived in PlayerContext, so every card re-rendered — and re-parsed its Markdown — four times a second while anything played; the playhead has its own PlayerTimeContext now. The socket context is split three ways (feed, chat via useWSChat, presence via useWSPresence), so typing, presence and chat messages leave the feeds alone. DumpCard, JournalCard and Markdown are memoized; card images use the server preview and load lazily; the feed cache is written when the page is left rather than after every pause in scrolling; the theme context value is memoized.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
34
api/db/migrations/0016_performance_indexes.ts
Normal file
34
api/db/migrations/0016_performance_indexes.ts
Normal file
@@ -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));
|
||||
`);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -22,3 +22,20 @@ export function parsePagination(
|
||||
);
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
/** One page of a list, and whether another follows. */
|
||||
export interface Page<T> {
|
||||
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<T>(rows: T[], limit: number): Page<T> {
|
||||
return rows.length > limit
|
||||
? { items: rows.slice(0, limit), hasMore: true }
|
||||
: { items: rows, hasMore: false };
|
||||
}
|
||||
|
||||
154
api/lib/serve-file.ts
Normal file
154
api/lib/serve-file.ts
Normal file
@@ -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<Uint8Array> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -4,17 +4,30 @@ import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
|
||||
async function serveIndexHtml(
|
||||
context: Context<Record<string, object>>,
|
||||
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<string, { mtime: number; html: string }>();
|
||||
|
||||
async function loadIndexHtml(root: string): Promise<string> {
|
||||
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<Record<string, object>>,
|
||||
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");
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<T>(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;`,
|
||||
|
||||
@@ -338,8 +338,9 @@ export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
/** The whole list's size — only where it's shown (search results). */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
|
||||
@@ -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<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PaginatedData<PublicUser>> = {
|
||||
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<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Dump> = { 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<Dump> = {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { 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<Dump> = {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
|
||||
@@ -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<PaginatedData<Notification>> = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> | undefined;
|
||||
|
||||
function ffmpegAvailable(): Promise<boolean> {
|
||||
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<void> {
|
||||
// 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<string, "jpg" | "png"> = {
|
||||
"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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, Promise<void>>();
|
||||
|
||||
async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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<PaginatedData<PublicUser>> = {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
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<PaginatedData<Dump>> = {
|
||||
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<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Dump> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<User> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
||||
): 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<void> {
|
||||
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<Dump> {
|
||||
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<Dump> {
|
||||
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<Dump> {
|
||||
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<Dump> {
|
||||
@@ -705,7 +729,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
// 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<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<Dump> {
|
||||
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<typeof isDumpRow>[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<Dump> {
|
||||
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<Playlist> {
|
||||
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<User> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Notification> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Playlist> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <head>,
|
||||
* and a "page" past this is a download, not something to scrape.
|
||||
*/
|
||||
export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Hands `body` on, and clears the request's deadline once it has been read to
|
||||
* the end or cancelled. Until then the deadline still applies: a server that
|
||||
* answers at once and then drips its body can't hold the request open.
|
||||
*/
|
||||
function withDeadline(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
timer: ReturnType<typeof setTimeout>,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const reader = body.getReader();
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
clearTimeout(timer);
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
clearTimeout(timer);
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a response's body as text, stopping at `maxBytes` (the rest is
|
||||
* cancelled, never downloaded).
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
res: Response,
|
||||
maxBytes = MAX_PAGE_BYTES,
|
||||
): Promise<string> {
|
||||
if (!res.body) return "";
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (size < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
size += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
const bytes = new Uint8Array(Math.min(size, maxBytes));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
const part = chunk.subarray(0, bytes.byteLength - offset);
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
if (offset >= bytes.byteLength) break;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** One request, no redirects followed. The timeout covers the body too. */
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
@@ -126,13 +199,15 @@ async function fetchOnce(
|
||||
): Promise<Response> {
|
||||
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<FetchRichContentResult> }
|
||||
>();
|
||||
|
||||
/** Forget every cached result (tests). */
|
||||
export function clearRichContentCache(): void {
|
||||
recent.clear();
|
||||
}
|
||||
|
||||
export function tryFetchRichContent(
|
||||
url: string,
|
||||
{ fresh = false }: { fresh?: boolean } = {},
|
||||
): Promise<FetchRichContentResult> {
|
||||
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<FetchRichContentResult> {
|
||||
try {
|
||||
|
||||
@@ -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<User> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Playlist, "isPublic" | "userId">,
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -139,7 +139,7 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
|
||||
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
|
||||
auth: owner.auth,
|
||||
})).data
|
||||
.total,
|
||||
.items.length,
|
||||
2,
|
||||
);
|
||||
assertEquals((await get("/api/categories/nope/dumps")).status, 404);
|
||||
|
||||
@@ -227,7 +227,7 @@ test("comment likes: count, conflicts, likers list and notifications", async ()
|
||||
const likers = await get<PaginatedData<PublicUser>>(
|
||||
`/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);
|
||||
|
||||
@@ -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<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.data.items.length, 1);
|
||||
assertEquals(page3.data.hasMore, false);
|
||||
|
||||
const mine = await get<PaginatedData<Dump>>("/api/dumps?limit=1", {
|
||||
const mine = await get<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<PublicUser>>(`/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<PaginatedData<Dump>>(path, opts)).data.total;
|
||||
(await get<PaginatedData<Dump>>(path, opts)).data.items.length;
|
||||
assertEquals(await total(), 1, sub);
|
||||
assertEquals(await total({ auth: other.auth }), 1, sub);
|
||||
assertEquals(
|
||||
|
||||
@@ -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<string, string[]> = {},
|
||||
): { 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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<PaginatedData<Playlist>>(path)).data.total, 1);
|
||||
assertEquals((await get<PaginatedData<Playlist>>(path)).data.items.length, 1);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.total,
|
||||
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.items
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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("<html>"));
|
||||
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(`<title>Once</title>`));
|
||||
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(`<title>Up</title>`);
|
||||
});
|
||||
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<Uint8Array>({
|
||||
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("<svg/>", { 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);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
|
||||
(await get<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<Notification>>("/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"],
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<AttachmentResponse>("/api/attachments", {
|
||||
|
||||
@@ -42,6 +42,8 @@ export function Avatar(
|
||||
title={username}
|
||||
style={sizeStyle}
|
||||
className="avatar-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<Float32Array | null>(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 (
|
||||
<img
|
||||
src={thumbOverride ?? fileUrl}
|
||||
src={dumpThumbnailUrl(dump, token)}
|
||||
alt={dump.fileName}
|
||||
className="rich-content-compact-thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ export function PlaylistCard(
|
||||
src={playlistImageUrl(playlist)}
|
||||
alt=""
|
||||
className="playlist-card-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
: <span className="playlist-card-icon">📋</span>}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function Thumbnail(
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() =>
|
||||
setAttempt(
|
||||
|
||||
@@ -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<PlayerContextValue>(
|
||||
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<PlayerTimeValue>(
|
||||
defaultPlayerTimeValue,
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<PlayerContext.Provider value={value}>
|
||||
{children}
|
||||
<PlayerTimeContext.Provider value={timeValue}>
|
||||
{children}
|
||||
</PlayerTimeContext.Provider>
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
style,
|
||||
colorScheme,
|
||||
setStyle: setStyleState,
|
||||
setColorScheme: setColorSchemeState,
|
||||
}}
|
||||
>
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<string, number>;
|
||||
myVotes: Set<string>;
|
||||
likeCounts: Record<string, number>;
|
||||
@@ -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<WSContextValue>(defaultWSValue);
|
||||
export const WSChatContext = createContext<WSChatContextValue>(
|
||||
defaultWSChatValue,
|
||||
);
|
||||
export const WSPresenceContext = createContext<WSPresenceContextValue>(
|
||||
defaultWSPresenceValue,
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<WSContext.Provider value={value}>
|
||||
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||
{children}
|
||||
</AvatarOverrideContext.Provider>
|
||||
<WSChatContext.Provider value={chatValue}>
|
||||
<WSPresenceContext.Provider value={presenceValue}>
|
||||
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||
{children}
|
||||
</AvatarOverrideContext.Provider>
|
||||
</WSPresenceContext.Provider>
|
||||
</WSChatContext.Provider>
|
||||
</WSContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
// 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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}</0> upvoted <1>{1}</1>"
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> was added to <1>{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"
|
||||
|
||||
|
||||
@@ -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}</0> a voté pour <1>{1}</1>"
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a été ajouté à <1>{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é"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
/** The whole list's size — only where it's shown (search results). */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -489,13 +489,13 @@ export function UserPublicProfile() {
|
||||
|
||||
const votesData: PaginatedData<RawDump> = votesBody.success
|
||||
? votesBody.data
|
||||
: { items: [], total: 0, hasMore: false };
|
||||
: { items: [], hasMore: false };
|
||||
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
|
||||
? playlistsBody.data
|
||||
: { items: [], total: 0, hasMore: false };
|
||||
: { items: [], hasMore: false };
|
||||
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
|
||||
? dumpsBody.data
|
||||
: { items: [], total: 0, hasMore: false };
|
||||
: { items: [], hasMore: false };
|
||||
|
||||
const profileUser = deserializePublicUser(userBody.data);
|
||||
const voteItems = votesData.items.map(deserializeDump);
|
||||
|
||||
@@ -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<HTMLMediaElement>("audio, video")!;
|
||||
@@ -221,13 +226,24 @@ describe("FilePreview", () => {
|
||||
});
|
||||
|
||||
test("images show inline, with the media token", () => {
|
||||
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} />, { 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(<FilePreview dump={d} compact />, { 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(<FilePreview dump={file("application/zip")} />);
|
||||
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(<FilePreview dump={file("video/mp4")} compact />, {
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<typeof PlayerContext>;
|
||||
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(
|
||||
<ul>
|
||||
<JournalCard
|
||||
dump={makeDump({
|
||||
id: "v",
|
||||
kind: "file",
|
||||
fileMime: "video/mp4",
|
||||
url: undefined,
|
||||
})}
|
||||
shape="tall"
|
||||
{...base}
|
||||
/>
|
||||
<JournalCard dump={dump} shape="tall" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe(
|
||||
"http://localhost:8000/api/thumbnails/v",
|
||||
dumpThumbnailUrl(dump),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<Wrapper>
|
||||
<Grab />
|
||||
<MemoProbe />
|
||||
<MemoClock />
|
||||
</Wrapper>,
|
||||
);
|
||||
const before = { controls, clock };
|
||||
act(() => onTimeUpdate(5, 100));
|
||||
act(() => onTimeUpdate(6, 100));
|
||||
expect(controls).toBe(before.controls);
|
||||
expect(clock).toBe(before.clock + 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<WSContextValue>;
|
||||
/** Any of the socket's three contexts' fields; each goes to its own. */
|
||||
ws?: Partial<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
|
||||
follows?: Partial<FollowContextValue>;
|
||||
categories?: Category[] | Partial<CategoriesContextValue>;
|
||||
player?: Partial<PlayerContextValue>;
|
||||
player?: Partial<PlayerContextValue & PlayerTimeValue>;
|
||||
}
|
||||
|
||||
function StubAuth(
|
||||
@@ -101,6 +111,17 @@ function StubAuth(
|
||||
);
|
||||
}
|
||||
|
||||
/** `defaults`, with whichever of its keys `overrides` sets. */
|
||||
function pick<T extends object>(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(
|
||||
<PlayerContext.Provider
|
||||
value={{ ...defaultPlayerValue, ...opts.player }}
|
||||
>
|
||||
<WSContext.Provider value={{ ...defaultWSValue, ...opts.ws }}>
|
||||
<FollowContext.Provider
|
||||
value={{ ...defaultFollowValue, isLoaded: true, ...opts.follows }}
|
||||
>
|
||||
<CategoriesContext.Provider
|
||||
value={categoriesValue(opts.categories)}
|
||||
>
|
||||
<Router {...opts}>{children}</Router>
|
||||
</CategoriesContext.Provider>
|
||||
</FollowContext.Provider>
|
||||
</WSContext.Provider>
|
||||
<PlayerTimeContext.Provider
|
||||
value={{
|
||||
currentTime: opts.player?.currentTime ??
|
||||
defaultPlayerTimeValue.currentTime,
|
||||
duration: opts.player?.duration ??
|
||||
defaultPlayerTimeValue.duration,
|
||||
}}
|
||||
>
|
||||
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
|
||||
<WSChatContext.Provider value={pick(defaultWSChatValue, opts.ws)}>
|
||||
<WSPresenceContext.Provider
|
||||
value={pick(defaultWSPresenceValue, opts.ws)}
|
||||
>
|
||||
<FollowContext.Provider
|
||||
value={{
|
||||
...defaultFollowValue,
|
||||
isLoaded: true,
|
||||
...opts.follows,
|
||||
}}
|
||||
>
|
||||
<CategoriesContext.Provider
|
||||
value={categoriesValue(opts.categories)}
|
||||
>
|
||||
<Router {...opts}>{children}</Router>
|
||||
</CategoriesContext.Provider>
|
||||
</FollowContext.Provider>
|
||||
</WSPresenceContext.Provider>
|
||||
</WSChatContext.Provider>
|
||||
</WSContext.Provider>
|
||||
</PlayerTimeContext.Provider>
|
||||
</PlayerContext.Provider>
|
||||
</StubAuth>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<VersionedDump, "id">): 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 (`<audio>`, `<img>`, …) can't send an `Authorization`
|
||||
@@ -7,20 +25,30 @@ import { API_URL } from "../config/api.ts";
|
||||
* the WebSocket connection (`?token=`).
|
||||
*/
|
||||
export function dumpFileUrl(
|
||||
dump: { id: string; fileSize?: number },
|
||||
dump: VersionedDump,
|
||||
token?: string | null,
|
||||
): string {
|
||||
const auth = token ? `&token=${encodeURIComponent(token)}` : "";
|
||||
return `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}${auth}`;
|
||||
return `${API_URL}/api/files/${dump.id}?v=${dumpFileVersion(dump)}${auth}`;
|
||||
}
|
||||
|
||||
/** Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}). */
|
||||
/**
|
||||
* Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}):
|
||||
* the custom thumbnail if it has one, else what the server makes from the file
|
||||
* — a video still, or a scaled-down copy of an image for cards to show instead
|
||||
* of the full upload.
|
||||
*/
|
||||
export function dumpThumbnailUrl(
|
||||
dump: { id: string },
|
||||
dump: VersionedDump,
|
||||
token?: string | null,
|
||||
): string {
|
||||
const auth = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
return `${API_URL}/api/thumbnails/${dump.id}${auth}`;
|
||||
const params = [
|
||||
...(dump.createdAt ? [`v=${dumpFileVersion(dump)}`] : []),
|
||||
...(token ? [`token=${encodeURIComponent(token)}`] : []),
|
||||
];
|
||||
return `${API_URL}/api/thumbnails/${dump.id}${
|
||||
params.length ? `?${params.join("&")}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function normalizeUrl(input: string): string {
|
||||
|
||||
Reference in New Issue
Block a user