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 up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||||
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
||||||
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
|
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
|
||||||
|
import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,6 +43,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||||
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
||||||
{ name: "0015_token_revocation", up: up0015TokenRevocation },
|
{ name: "0015_token_revocation", up: up0015TokenRevocation },
|
||||||
|
{ name: "0016_performance_indexes", up: up0016PerformanceIndexes },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
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
|
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 ON dumps(url);
|
||||||
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
|
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_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_order ON playlist_dumps(playlist_id, position);
|
||||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
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_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_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_to ON dump_backlinks(to_dump_id);
|
||||||
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_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;
|
WHERE followed_playlist_id IS NOT NULL;
|
||||||
|
|
||||||
CREATE INDEX idx_follows_follower ON follows(follower_id);
|
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 (
|
CREATE TABLE invites (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@@ -166,6 +173,10 @@ CREATE TABLE invites (
|
|||||||
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE attachments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
resource_id TEXT,
|
resource_id TEXT,
|
||||||
@@ -183,6 +194,8 @@ CREATE TABLE password_reset_tokens (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE notifications (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -208,3 +221,4 @@ CREATE TABLE chat_messages (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
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_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 };
|
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);
|
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||||
|
|
||||||
async function serveIndexHtml(
|
// index.html with its placeholders filled, by root. Kept until the file
|
||||||
context: Context<Record<string, object>>,
|
// changes (a rebuild under a running server): a stat per navigation instead of
|
||||||
root: string,
|
// a read and four replaceAll.
|
||||||
) {
|
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||||
const filePath = `${root}/index.html`;
|
|
||||||
const raw = await Deno.readTextFile(filePath);
|
async function loadIndexHtml(root: string): Promise<string> {
|
||||||
const html = raw
|
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_NAME__", OG_SITE_NAME)
|
||||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
.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";
|
context.response.type = "text/html";
|
||||||
// Always revalidated: it names the hashed chunks of the current build.
|
// Always revalidated: it names the hashed chunks of the current build.
|
||||||
context.response.headers.set("Cache-Control", "no-cache");
|
context.response.headers.set("Cache-Control", "no-cache");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "@oak/oak";
|
import type { Context } from "@oak/oak";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { serveFile } from "./serve-file.ts";
|
||||||
import {
|
import {
|
||||||
ALLOWED_IMAGE_MIMES,
|
ALLOWED_IMAGE_MIMES,
|
||||||
ATTACHMENTS_DIR,
|
ATTACHMENTS_DIR,
|
||||||
@@ -67,21 +68,13 @@ export async function serveUploadedFile(
|
|||||||
cacheMaxAge = 3600,
|
cacheMaxAge = 3600,
|
||||||
isPrivate = false,
|
isPrivate = false,
|
||||||
): Promise<boolean> {
|
): 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("Content-Disposition", "inline");
|
||||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
ctx.response.headers.set(
|
|
||||||
"Cache-Control",
|
|
||||||
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
|
||||||
);
|
|
||||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||||
ctx.response.body = data;
|
const served = await serveFile(ctx, filePath, {
|
||||||
return true;
|
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";
|
import { runMigrations } from "../db/migrate.ts";
|
||||||
|
|
||||||
export const db = new DatabaseSync(DB_PATH);
|
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);
|
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
|
// Purge expired/used password reset tokens on startup
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
|
`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> {
|
export interface PaginatedData<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
|
/** The whole list's size — only where it's shown (search results). */
|
||||||
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResults {
|
export interface SearchResults {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ router.get("/:slug", (ctx) => {
|
|||||||
router.get("/:slug/dumps", async (ctx) => {
|
router.get("/:slug/dumps", async (ctx) => {
|
||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const { items, total } = listDumpsByCategory(
|
const { items, hasMore } = listDumpsByCategory(
|
||||||
ctx.params.slug,
|
ctx.params.slug,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -53,7 +53,7 @@ router.get("/:slug/dumps", async (ctx) => {
|
|||||||
);
|
);
|
||||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
|||||||
const dumpId = getCommentDumpId(ctx.params.commentId);
|
const dumpId = getCommentDumpId(ctx.params.commentId);
|
||||||
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
|
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const { items, total } = getCommentLikers(
|
const { items, hasMore } = getCommentLikers(
|
||||||
ctx.params.commentId,
|
ctx.params.commentId,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -105,8 +105,7 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: items.map(toPublicUser),
|
items: items.map(toPublicUser),
|
||||||
total,
|
hasMore,
|
||||||
hasMore: page * limit < total,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
|
|||||||
@@ -130,13 +130,12 @@ router.get("/:dumpId/voters", async (ctx) => {
|
|||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: items.map(toPublicUser),
|
items: items.map(toPublicUser),
|
||||||
total,
|
hasMore,
|
||||||
hasMore: page * limit < total,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
@@ -165,10 +164,10 @@ router.get("/:dumpId/playlists", async (ctx) => {
|
|||||||
router.get("/", async (ctx) => {
|
router.get("/", async (ctx) => {
|
||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import { getDump } from "../services/dump-service.ts";
|
|
||||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||||
import { authenticateMedia } from "../lib/auth.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" });
|
const router = new Router({ prefix: "/api/files" });
|
||||||
|
|
||||||
@@ -63,17 +64,7 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = `${DUMPS_DIR}/${dumpId}`;
|
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);
|
const inline = isInlineSafe(dump.fileMime);
|
||||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
|
||||||
ctx.response.headers.set(
|
ctx.response.headers.set(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
contentDisposition(inline ? "inline" : "attachment", dump.fileName),
|
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
|
// The URL may carry a media token; don't pass it on to wherever the file
|
||||||
// links.
|
// links.
|
||||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
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
|
// The app's URLs carry the file's version (see dumpFileVersion), so one
|
||||||
// ranges, another unit — is a header RFC 9110 lets us ignore, so the whole
|
// naming the current version never changes and is cached for good; any
|
||||||
// file goes out with a 200 rather than a 416 for ranges that were fine.
|
// other URL is revalidated against the ETag. A private dump's bytes must not
|
||||||
const match = ctx.request.headers.get("Range")?.match(
|
// sit in a shared cache.
|
||||||
/^bytes=(\d*)-(\d*)$/,
|
const scope = dump.isPrivate ? "private" : "public";
|
||||||
);
|
const current = ctx.request.url.searchParams.get("v") ===
|
||||||
|
dumpFileVersion(dump);
|
||||||
if (match) {
|
const served = await serveFile(ctx, path, {
|
||||||
// "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes).
|
mime: dump.fileMime,
|
||||||
let start: number;
|
cacheControl: current
|
||||||
let end: number;
|
? `${scope}, max-age=31536000, immutable`
|
||||||
if (match[1]) {
|
: `${scope}, no-cache`,
|
||||||
start = parseInt(match[1], 10);
|
ranges: true,
|
||||||
end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1;
|
});
|
||||||
} else {
|
if (!served) {
|
||||||
const suffix = parseInt(match[2], 10);
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type FollowStatus,
|
type FollowStatus,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { parsePagination } from "../lib/pagination.ts";
|
import { type Page, parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
followPlaylist,
|
followPlaylist,
|
||||||
followUser,
|
followUser,
|
||||||
@@ -29,7 +29,7 @@ function resolveCategoryFilter(
|
|||||||
return getCategoryIdBySlug(slug) ?? "missing";
|
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" });
|
const router = new Router({ prefix: "/api/follows" });
|
||||||
|
|
||||||
@@ -46,13 +46,12 @@ router.get("/status", authMiddleware, (ctx) => {
|
|||||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||||
const { items, total } = categoryId === "missing"
|
const { items, hasMore } = categoryId === "missing"
|
||||||
? EMPTY_FEED
|
? EMPTY_FEED
|
||||||
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
|
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
|
||||||
const data: PaginatedData<Dump> = {
|
const data: PaginatedData<Dump> = {
|
||||||
items,
|
items,
|
||||||
total,
|
hasMore,
|
||||||
hasMore: page * limit < total,
|
|
||||||
};
|
};
|
||||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||||
ctx.response.body = body;
|
ctx.response.body = body;
|
||||||
@@ -62,7 +61,7 @@ router.get("/feed/users", authMiddleware, (ctx) => {
|
|||||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||||
const { items, total } = categoryId === "missing"
|
const { items, hasMore } = categoryId === "missing"
|
||||||
? EMPTY_FEED
|
? EMPTY_FEED
|
||||||
: getFollowedPlaylistsDumpFeed(
|
: getFollowedPlaylistsDumpFeed(
|
||||||
ctx.state.user.userId,
|
ctx.state.user.userId,
|
||||||
@@ -72,8 +71,7 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
|
|||||||
);
|
);
|
||||||
const data: PaginatedData<Dump> = {
|
const data: PaginatedData<Dump> = {
|
||||||
items,
|
items,
|
||||||
total,
|
hasMore,
|
||||||
hasMore: page * limit < total,
|
|
||||||
};
|
};
|
||||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||||
ctx.response.body = body;
|
ctx.response.body = body;
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
|||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
}
|
}
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const { items, total } = getNotificationsForUser(
|
const { items, hasMore } = getNotificationsForUser(
|
||||||
ctx.state.user.userId,
|
ctx.state.user.userId,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
const responseBody: APIResponse<PaginatedData<Notification>> = {
|
const responseBody: APIResponse<PaginatedData<Notification>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,74 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
|||||||
ctx.response.body = responseBody;
|
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
|
* Proxy an external image through the server so HTTP thumbnail URLs don't
|
||||||
* trigger mixed-content blocks when the frontend is served over HTTPS.
|
* trigger mixed-content blocks when the frontend is served over HTTPS.
|
||||||
@@ -61,6 +129,12 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cached = readImageCache(url);
|
||||||
|
if (cached) {
|
||||||
|
sendImage(ctx, cached.bytes, cached.contentType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url, 8000);
|
const res = await fetchWithTimeout(url, 8000);
|
||||||
// Raster formats only. The bytes go out under this site's origin, so an
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.response.headers.set("Content-Type", contentType);
|
writeImageCache(url, bytes, contentType);
|
||||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
sendImage(ctx, bytes, contentType);
|
||||||
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;
|
|
||||||
} catch {
|
} catch {
|
||||||
ctx.response.status = 502;
|
ctx.response.status = 502;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,79 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
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 { serveUploadedFile } from "../lib/upload.ts";
|
||||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||||
import { authenticateMedia } from "../lib/auth.ts";
|
import { authenticateMedia } from "../lib/auth.ts";
|
||||||
|
|
||||||
const router = new Router({ prefix: "/api/thumbnails" });
|
const router = new Router({ prefix: "/api/thumbnails" });
|
||||||
|
|
||||||
async function ffmpegAvailable(): Promise<boolean> {
|
// 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 {
|
try {
|
||||||
const cmd = new Deno.Command("ffmpeg", {
|
const { success } = await new Deno.Command("ffmpeg", {
|
||||||
args: ["-version"],
|
args: ["-version"],
|
||||||
stderr: "null",
|
stderr: "null",
|
||||||
stdout: "null",
|
stdout: "null",
|
||||||
});
|
}).output();
|
||||||
const { success } = await cmd.output();
|
|
||||||
return success;
|
return success;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
return ffmpegProbe;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateThumbnail(
|
// The upload's type is only what the uploader claimed, and ffmpeg probes the
|
||||||
srcPath: string,
|
// real format from the bytes — a "video" could be an HLS or concat playlist
|
||||||
outPath: string,
|
// pointing ffmpeg at local files or internal URLs. Read nothing but the file
|
||||||
): Promise<void> {
|
// 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 });
|
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||||
const cmd = new Deno.Command("ffmpeg", {
|
// Written beside and renamed over, so a concurrent reader never gets half.
|
||||||
args: [
|
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||||
// The upload's type is only what the uploader claimed, and ffmpeg probes
|
const { success } = await new Deno.Command("ffmpeg", {
|
||||||
// the real format from the bytes — a "video" could be an HLS or concat
|
args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath],
|
||||||
// playlist pointing ffmpeg at local files or internal URLs. Read nothing
|
stdout: "null",
|
||||||
// but the file itself, and only as an actual video container.
|
stderr: "null",
|
||||||
"-protocol_whitelist",
|
}).output();
|
||||||
"file",
|
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",
|
"-format_whitelist",
|
||||||
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc",
|
VIDEO_FORMATS,
|
||||||
"-ss",
|
"-ss",
|
||||||
"00:00:01",
|
"00:00:01",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -46,14 +84,45 @@ async function generateThumbnail(
|
|||||||
"scale=320:-1",
|
"scale=320:-1",
|
||||||
"-f",
|
"-f",
|
||||||
"image2",
|
"image2",
|
||||||
"-y",
|
"-c:v",
|
||||||
outPath,
|
"mjpeg",
|
||||||
],
|
], outPath);
|
||||||
stdout: "null",
|
}
|
||||||
stderr: "null",
|
|
||||||
});
|
function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||||
const { success } = await cmd.output();
|
return runFfmpeg([
|
||||||
if (!success) throw new Error("ffmpeg failed");
|
"-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) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
@@ -70,67 +139,80 @@ router.get("/:dumpId", async (ctx) => {
|
|||||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||||
// A private dump's thumbnail must not sit in a shared cache.
|
// A private dump's thumbnail must not sit in a shared cache. The app's URLs
|
||||||
const cacheControl = dump.isPrivate
|
// carry the file's version (see dumpFileVersion): what's generated from the
|
||||||
? "private, max-age=31536000, immutable"
|
// file is cached for good under the current one, and revalidated otherwise.
|
||||||
: "public, max-age=31536000, immutable";
|
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);
|
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||||
if (customThumb) {
|
if (customThumb) {
|
||||||
|
// Replaceable without the URL changing: always revalidated (a 304 when
|
||||||
|
// unchanged).
|
||||||
const served = await serveUploadedFile(
|
const served = await serveUploadedFile(
|
||||||
ctx,
|
ctx,
|
||||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||||
customThumb.mime,
|
customThumb.mime,
|
||||||
3600,
|
0,
|
||||||
dump.isPrivate,
|
dump.isPrivate,
|
||||||
);
|
);
|
||||||
if (served) return;
|
if (served) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {
|
const mime = dump.kind === "file" ? dump.fileMime ?? "" : "";
|
||||||
throw new APIException(
|
const srcPath = `${DUMPS_DIR}/${dump.id}`;
|
||||||
APIErrorCode.NOT_FOUND,
|
|
||||||
404,
|
|
||||||
"No video for this dump",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbPath = `${THUMBNAILS_DIR}/${dumpId}.jpg`;
|
if (mime.startsWith("video/")) {
|
||||||
|
const thumbPath = `${THUMBNAILS_DIR}/${dump.id}.jpg`;
|
||||||
// Serve cached thumbnail if it exists
|
if (
|
||||||
try {
|
!await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath))
|
||||||
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 (!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(
|
throw new APIException(
|
||||||
APIErrorCode.NOT_FOUND,
|
APIErrorCode.NOT_FOUND,
|
||||||
404,
|
404,
|
||||||
"Could not generate thumbnail",
|
"Could not generate thumbnail",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
await serveFile(ctx, thumbPath, {
|
||||||
|
mime: "image/jpeg",
|
||||||
|
cacheControl: generatedCache,
|
||||||
|
})
|
||||||
|
) return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await Deno.readFile(thumbPath);
|
if (mime.startsWith("image/")) {
|
||||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
// A scaled-down copy for the feed; the original when there's none to make
|
||||||
ctx.response.headers.set("Cache-Control", cacheControl);
|
// (a GIF, no ffmpeg, an image ffmpeg can't read).
|
||||||
ctx.response.body = data;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"No thumbnail for this dump",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -380,13 +380,12 @@ router.get("/by-id/:userId", (ctx) => {
|
|||||||
router.get("/:username/followed-users", (ctx) => {
|
router.get("/:username/followed-users", (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: items.map(toPublicUser),
|
items: items.map(toPublicUser),
|
||||||
total,
|
hasMore,
|
||||||
hasMore: page * limit < total,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
@@ -396,10 +395,10 @@ router.get("/:username/followed-users", (ctx) => {
|
|||||||
router.get("/:username/followed-playlists", (ctx) => {
|
router.get("/:username/followed-playlists", (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -409,7 +408,7 @@ router.get("/:username/playlists", async (ctx) => {
|
|||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const requestingUserId = await parseOptionalAuth(ctx);
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const { items, total } = listPlaylistsByUser(
|
const { items, hasMore } = listPlaylistsByUser(
|
||||||
user.id,
|
user.id,
|
||||||
requestingUserId,
|
requestingUserId,
|
||||||
page,
|
page,
|
||||||
@@ -417,7 +416,7 @@ router.get("/:username/playlists", async (ctx) => {
|
|||||||
);
|
);
|
||||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -447,10 +446,15 @@ router.get("/:username/dumps", async (ctx) => {
|
|||||||
const requestingUserId = await parseOptionalAuth(ctx);
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const includePrivate = requestingUserId === user.id;
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -460,7 +464,7 @@ router.get("/:username/votes", async (ctx) => {
|
|||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const requestingUserId = await parseOptionalAuth(ctx);
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
const { items, total } = getVotedDumpsByUser(
|
const { items, hasMore } = getVotedDumpsByUser(
|
||||||
user.id,
|
user.id,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -468,7 +472,7 @@ router.get("/:username/votes", async (ctx) => {
|
|||||||
);
|
);
|
||||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total },
|
data: { items, hasMore },
|
||||||
};
|
};
|
||||||
ctx.response.body = responseBody;
|
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";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
export function createAttachment(id: string, mime: string): void {
|
export function createAttachment(id: string, mime: string): void {
|
||||||
@@ -34,9 +34,11 @@ const ATTACHMENT_RE =
|
|||||||
*/
|
*/
|
||||||
export function linkAttachments(text: string, resourceId: string): void {
|
export function linkAttachments(text: string, resourceId: string): void {
|
||||||
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
||||||
for (const id of ids) {
|
if (ids.length === 0) return;
|
||||||
db.prepare(
|
const update = db.prepare(
|
||||||
"UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
"UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
||||||
).run(resourceId, id);
|
);
|
||||||
}
|
transaction(() => {
|
||||||
|
for (const id of ids) update.run(resourceId, id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
|
transaction,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
import { UUID_RE } from "../lib/slugify.ts";
|
import { UUID_RE } from "../lib/slugify.ts";
|
||||||
|
|
||||||
@@ -78,23 +79,19 @@ export function relinkBacklinks(
|
|||||||
|
|
||||||
targetIds.delete(fromDumpId); // no self-links
|
targetIds.delete(fromDumpId); // no self-links
|
||||||
|
|
||||||
db.exec("BEGIN;");
|
transaction(() => {
|
||||||
try {
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
||||||
).run(sourceType, sourceId);
|
).run(sourceType, sourceId);
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
for (const toDumpId of targetIds) {
|
const insert = db.prepare(
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at)
|
`INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?);`,
|
||||||
).run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
|
);
|
||||||
}
|
for (const toDumpId of targetIds) {
|
||||||
db.exec("COMMIT;");
|
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
|
||||||
} catch (err) {
|
|
||||||
db.exec("ROLLBACK;");
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */
|
/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
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 {
|
import {
|
||||||
BANDCAMP_HOST_RE,
|
BANDCAMP_HOST_RE,
|
||||||
parseTralbum,
|
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) {
|
if (!parsed) {
|
||||||
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
|
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
|
||||||
// with nothing streamable — all cases where the client falls back to the
|
// with nothing streamable — all cases where the client falls back to the
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isCategoryRow,
|
isCategoryRow,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
|
transaction,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||||
import { VALIDATION } from "../config.ts";
|
import { VALIDATION } from "../config.ts";
|
||||||
|
|
||||||
@@ -217,6 +219,7 @@ export function setDumpCategories(
|
|||||||
assertCategoriesExist(categoryIds);
|
assertCategoriesExist(categoryIds);
|
||||||
const unique = [...new Set(categoryIds)];
|
const unique = [...new Set(categoryIds)];
|
||||||
|
|
||||||
|
transaction(() => {
|
||||||
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||||
@@ -224,6 +227,7 @@ export function setDumpCategories(
|
|||||||
for (const categoryId of unique) {
|
for (const categoryId of unique) {
|
||||||
insert.run(dumpId, categoryId);
|
insert.run(dumpId, categoryId);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
||||||
@@ -251,7 +255,7 @@ export function listDumpsByCategory(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
requestingUserId?: string,
|
requestingUserId?: string,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const category = getCategoryBySlug(slug);
|
const category = getCategoryBySlug(slug);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@@ -266,16 +270,9 @@ export function listDumpsByCategory(
|
|||||||
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
||||||
WHERE dc.category_id = ? AND ${privacyClause}
|
WHERE dc.category_id = ? AND ${privacyClause}
|
||||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(category.id, ...privacyParams, limit, offset);
|
).all(category.id, ...privacyParams, limit + 1, 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;
|
|
||||||
|
|
||||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
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 { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||||
|
|
||||||
export interface LikeResult {
|
export interface LikeResult {
|
||||||
@@ -105,13 +106,9 @@ export function getCommentLikers(
|
|||||||
commentId: string,
|
commentId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: User[]; total: number } {
|
): Page<User> {
|
||||||
const offset = (page - 1) * limit;
|
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(
|
const rawRows = db.prepare(
|
||||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
`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,
|
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
|
INNER JOIN comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id
|
||||||
ORDER BY cl.created_at DESC
|
ORDER BY cl.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(commentId, limit, offset);
|
).all(commentId, limit + 1, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isUserRow)) {
|
if (!rawRows.every(isUserRow)) {
|
||||||
throw new APIException(
|
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,
|
dumpApiToRow,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
|
transaction,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
fetchRichContent,
|
fetchRichContent,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
@@ -97,6 +99,8 @@ export async function createUrlDump(
|
|||||||
const isPrivate = request.isPrivate ?? false;
|
const isPrivate = request.isPrivate ?? false;
|
||||||
const slug = makeSlug(title, dumpId);
|
const slug = makeSlug(title, dumpId);
|
||||||
|
|
||||||
|
const categoryIds = [...new Set(request.categoryIds ?? [])];
|
||||||
|
transaction(() => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
@@ -113,9 +117,8 @@ export async function createUrlDump(
|
|||||||
richContent ? JSON.stringify(richContent) : null,
|
richContent ? JSON.stringify(richContent) : null,
|
||||||
isPrivate ? 1 : 0,
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryIds = [...new Set(request.categoryIds ?? [])];
|
|
||||||
setDumpCategories(dumpId, categoryIds);
|
setDumpCategories(dumpId, categoryIds);
|
||||||
|
});
|
||||||
|
|
||||||
const dump: Dump = {
|
const dump: Dump = {
|
||||||
id: dumpId,
|
id: dumpId,
|
||||||
@@ -176,11 +179,12 @@ export async function createFileDump(
|
|||||||
const dedupedCategoryIds = [...new Set(categoryIds)];
|
const dedupedCategoryIds = [...new Set(categoryIds)];
|
||||||
|
|
||||||
await Deno.mkdir(DUMPS_DIR, { recursive: true });
|
await Deno.mkdir(DUMPS_DIR, { recursive: true });
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
|
||||||
|
|
||||||
try {
|
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());
|
||||||
|
|
||||||
|
transaction(() => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
@@ -198,6 +202,7 @@ export async function createFileDump(
|
|||||||
isPrivate ? 1 : 0,
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
setDumpCategories(dumpId, dedupedCategoryIds);
|
setDumpCategories(dumpId, dedupedCategoryIds);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Roll back the file if DB insert fails
|
// Roll back the file if DB insert fails
|
||||||
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
|
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
|
||||||
@@ -232,6 +237,29 @@ export async function createFileDump(
|
|||||||
return dump;
|
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.
|
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||||
function fetchDump(idOrSlug: string): Dump {
|
function fetchDump(idOrSlug: string): Dump {
|
||||||
const row = UUID_RE.test(idOrSlug)
|
const row = UUID_RE.test(idOrSlug)
|
||||||
@@ -296,23 +324,16 @@ export function listDumps(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
requestingUserId?: string,
|
requestingUserId?: string,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
// Show public dumps + the requesting user's own private dumps
|
// Show public dumps + the requesting user's own private dumps
|
||||||
const rows = requestingUserId
|
const rows = requestingUserId
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
`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(
|
: db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(limit, offset);
|
).all(limit + 1, 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;
|
|
||||||
|
|
||||||
if (!rows || !rows.every(isDumpRow)) {
|
if (!rows || !rows.every(isDumpRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -324,7 +345,7 @@ export function listDumps(
|
|||||||
|
|
||||||
const items = rows.map(dumpRowToApi);
|
const items = rows.map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
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);
|
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}`;
|
const filePath = `${DUMPS_DIR}/${dumpId}`;
|
||||||
// Read old file contents so we can restore on DB failure
|
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
||||||
const oldData = await Deno.readFile(filePath).catch(() => null);
|
const backupPath = `${tmpPath}.old`;
|
||||||
await Deno.writeFile(filePath, data);
|
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 now = new Date();
|
||||||
const finalTitle = title?.trim() || dump.title;
|
const finalTitle = title?.trim() || dump.title;
|
||||||
@@ -561,6 +595,7 @@ export async function replaceFileDump(
|
|||||||
: dump.slug;
|
: dump.slug;
|
||||||
const finalPrivate = isPrivate ?? dump.isPrivate;
|
const finalPrivate = isPrivate ?? dump.isPrivate;
|
||||||
try {
|
try {
|
||||||
|
transaction(() => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(
|
).run(
|
||||||
@@ -574,17 +609,21 @@ export async function replaceFileDump(
|
|||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
dumpId,
|
dumpId,
|
||||||
);
|
);
|
||||||
|
if (categoryIds !== undefined) {
|
||||||
|
setDumpCategories(dumpId, [...new Set(categoryIds)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Roll back the file to its previous contents on DB failure
|
// Put the previous file back on DB failure
|
||||||
if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {});
|
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
|
||||||
else await Deno.remove(filePath).catch(() => {});
|
else await Deno.remove(filePath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (hadOld) await Deno.remove(backupPath).catch(() => {});
|
||||||
|
await removeGeneratedThumbnails(dumpId);
|
||||||
|
|
||||||
if (categoryIds !== undefined) {
|
if (categoryIds !== undefined) {
|
||||||
const ids = [...new Set(categoryIds)];
|
dump.categoryIds = [...new Set(categoryIds)];
|
||||||
setDumpCategories(dumpId, ids);
|
|
||||||
dump.categoryIds = ids;
|
|
||||||
} else {
|
} else {
|
||||||
attachCategoryIds([dump]);
|
attachCategoryIds([dump]);
|
||||||
}
|
}
|
||||||
@@ -616,15 +655,12 @@ export function getDumpsByUser(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
includePrivate: boolean,
|
includePrivate: boolean,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
|
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset);
|
).all(userId, limit + 1, offset);
|
||||||
const totalRow = db.prepare(
|
|
||||||
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
|
|
||||||
).get(userId) as { count: number } | undefined;
|
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
@@ -634,7 +670,7 @@ export function getDumpsByUser(
|
|||||||
}
|
}
|
||||||
const items = rows.map(dumpRowToApi);
|
const items = rows.map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
attachCategoryIds(items);
|
||||||
return { items, total: totalRow?.count ?? 0 };
|
return pageOf(items, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVotedDumpsByUser(
|
export function getVotedDumpsByUser(
|
||||||
@@ -642,11 +678,9 @@ export function getVotedDumpsByUser(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
requestingUserId: string | null,
|
requestingUserId: string | null,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const dumpCols = SELECT_COLS_ALIASED;
|
const dumpCols = SELECT_COLS_ALIASED;
|
||||||
|
|
||||||
let totalRow: { count: number } | undefined;
|
|
||||||
let rows: unknown[];
|
let rows: unknown[];
|
||||||
|
|
||||||
if (requestingUserId === userId) {
|
if (requestingUserId === userId) {
|
||||||
@@ -657,12 +691,7 @@ export function getVotedDumpsByUser(
|
|||||||
INNER JOIN votes v ON d.id = v.dump_id
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, userId, limit, offset);
|
).all(userId, userId, limit + 1, 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;
|
|
||||||
} else {
|
} else {
|
||||||
rows = db.prepare(
|
rows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols}
|
||||||
@@ -670,12 +699,7 @@ export function getVotedDumpsByUser(
|
|||||||
INNER JOIN votes v ON d.id = v.dump_id
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
WHERE v.user_id = ? AND d.is_private = 0
|
WHERE v.user_id = ? AND d.is_private = 0
|
||||||
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset);
|
).all(userId, limit + 1, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
@@ -687,7 +711,7 @@ export function getVotedDumpsByUser(
|
|||||||
}
|
}
|
||||||
const items = rows.map(dumpRowToApi);
|
const items = rows.map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
attachCategoryIds(items);
|
||||||
return { items, total: totalRow?.count ?? 0 };
|
return pageOf(items, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
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
|
// 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
|
// rename the dump to its bare hostname, so bail out instead — the caller
|
||||||
// surfaces the error and nothing is lost.
|
// 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) {
|
if (!ok) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
@@ -767,7 +793,11 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dump.kind === "file") {
|
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);
|
broadcastDumpDeleted(dumpId);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
playlistRowToApi,
|
playlistRowToApi,
|
||||||
userRowToApi,
|
userRowToApi,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { attachCategoryIds } from "./category-service.ts";
|
import { attachCategoryIds } from "./category-service.ts";
|
||||||
|
|
||||||
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
||||||
@@ -141,7 +142,7 @@ export function getFollowedUsersDumpFeed(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Optional category filter: join the membership table and constrain to it.
|
// Optional category filter: join the membership table and constrain to it.
|
||||||
@@ -159,16 +160,7 @@ export function getFollowedUsersDumpFeed(
|
|||||||
AND d.is_private = 0
|
AND d.is_private = 0
|
||||||
ORDER BY d.created_at DESC
|
ORDER BY d.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(...catParams, followerId, limit, offset);
|
).all(...catParams, followerId, limit + 1, 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;
|
|
||||||
|
|
||||||
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||||
if (!userFeedRows.every(isDumpRow)) {
|
if (!userFeedRows.every(isDumpRow)) {
|
||||||
@@ -180,7 +172,7 @@ export function getFollowedUsersDumpFeed(
|
|||||||
}
|
}
|
||||||
const items = userFeedRows.map(dumpRowToApi);
|
const items = userFeedRows.map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
attachCategoryIds(items);
|
||||||
return { items, total: totalRow?.count ?? 0 };
|
return pageOf(items, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Followed-playlists dump feed ──────────────────────────────────────────────
|
// ── Followed-playlists dump feed ──────────────────────────────────────────────
|
||||||
@@ -190,7 +182,7 @@ export function getFollowedPlaylistsDumpFeed(
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
): { items: Dump[]; total: number } {
|
): Page<Dump> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const catJoin = categoryId
|
const catJoin = categoryId
|
||||||
@@ -211,19 +203,7 @@ export function getFollowedPlaylistsDumpFeed(
|
|||||||
GROUP BY d.id
|
GROUP BY d.id
|
||||||
ORDER BY MAX(pd.added_at) DESC
|
ORDER BY MAX(pd.added_at) DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(...catParams, followerId, limit, offset);
|
).all(...catParams, followerId, limit + 1, 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;
|
|
||||||
|
|
||||||
if (!rawRows.every(isDumpRow)) {
|
if (!rawRows.every(isDumpRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -234,7 +214,7 @@ export function getFollowedPlaylistsDumpFeed(
|
|||||||
}
|
}
|
||||||
const items = rawRows.map(dumpRowToApi);
|
const items = rawRows.map(dumpRowToApi);
|
||||||
attachCategoryIds(items);
|
attachCategoryIds(items);
|
||||||
return { items, total: totalRow?.count ?? 0 };
|
return pageOf(items, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
||||||
@@ -243,15 +223,9 @@ export function getFollowedPlaylistsByUser(
|
|||||||
userId: string,
|
userId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: Playlist[]; total: number } {
|
): Page<Playlist> {
|
||||||
const offset = (page - 1) * limit;
|
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(
|
const rawRows = db.prepare(
|
||||||
`SELECT p.*, u.username as owner_username,
|
`SELECT p.*, u.username as owner_username,
|
||||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
(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
|
AND p.is_public = 1
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset);
|
).all(userId, limit + 1, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isPlaylistRow)) {
|
if (!rawRows.every(isPlaylistRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -271,7 +245,7 @@ export function getFollowedPlaylistsByUser(
|
|||||||
"Malformed playlist data",
|
"Malformed playlist data",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
|
return pageOf(rawRows.map(playlistRowToApi), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Followed users (as user objects) ─────────────────────────────────────────
|
// ── Followed users (as user objects) ─────────────────────────────────────────
|
||||||
@@ -280,13 +254,9 @@ export function getFollowedUsersByUser(
|
|||||||
userId: string,
|
userId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: User[]; total: number } {
|
): Page<User> {
|
||||||
const offset = (page - 1) * limit;
|
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(
|
const rawRows = db.prepare(
|
||||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
`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,
|
u.avatar_mime, u.description, u.invited_by, u.email,
|
||||||
@@ -297,7 +267,7 @@ export function getFollowedUsersByUser(
|
|||||||
WHERE f.follower_id = ?
|
WHERE f.follower_id = ?
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset);
|
).all(userId, limit + 1, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isUserRow)) {
|
if (!rawRows.every(isUserRow)) {
|
||||||
throw new APIException(
|
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,
|
UserDumpPostedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { APIErrorCode, APIException } 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";
|
import { sendToUser } from "./ws-service.ts";
|
||||||
|
|
||||||
// Regex: matches @username not already inside a markdown link ([...] or (...)
|
// Regex: matches @username not already inside a markdown link ([...] or (...)
|
||||||
@@ -54,15 +60,11 @@ export function getNotificationsForUser(
|
|||||||
userId: string,
|
userId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: Notification[]; total: number } {
|
): Page<Notification> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const rawRows = db.prepare(
|
const rawRows = db.prepare(
|
||||||
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset);
|
).all(userId, limit + 1, offset);
|
||||||
|
|
||||||
const totalRow = db.prepare(
|
|
||||||
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
|
|
||||||
).get(userId) as { count: number } | undefined;
|
|
||||||
|
|
||||||
if (!rawRows.every(isNotificationRow)) {
|
if (!rawRows.every(isNotificationRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -71,10 +73,7 @@ export function getNotificationsForUser(
|
|||||||
"Malformed notification data",
|
"Malformed notification data",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return pageOf(rawRows.map(notificationRowToApi), limit);
|
||||||
items: rawRows.map(notificationRowToApi),
|
|
||||||
total: totalRow?.count ?? 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUnreadCount(userId: string): number {
|
export function getUnreadCount(userId: string): number {
|
||||||
@@ -179,6 +178,10 @@ export function notifyUserFollowersNewDump(
|
|||||||
VALUES (?, ?, ?, ?, 0, ?, ?);`,
|
VALUES (?, ?, ?, ?, 0, ?, ?);`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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) {
|
for (const row of followerRows) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const result = insert.run(
|
const result = insert.run(
|
||||||
@@ -203,6 +206,7 @@ export function notifyUserFollowersNewDump(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyDumpOwnerUpvote(
|
export function notifyDumpOwnerUpvote(
|
||||||
@@ -350,6 +354,7 @@ export function notifyPlaylistFollowersNewDump(
|
|||||||
`SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`,
|
`SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`,
|
||||||
).all(playlistId) as { follower_id: string }[];
|
).all(playlistId) as { follower_id: string }[];
|
||||||
|
|
||||||
|
transaction(() => {
|
||||||
for (const row of followerRows) {
|
for (const row of followerRows) {
|
||||||
createNotification(
|
createNotification(
|
||||||
row.follower_id,
|
row.follower_id,
|
||||||
@@ -358,4 +363,5 @@ export function notifyPlaylistFollowersNewDump(
|
|||||||
`pdump:${playlistId}:${dumpId}`,
|
`pdump:${playlistId}:${dumpId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
isDumpRow,
|
isDumpRow,
|
||||||
isPlaylistRow,
|
isPlaylistRow,
|
||||||
playlistRowToApi,
|
playlistRowToApi,
|
||||||
|
transaction,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
broadcastPlaylistCreated,
|
broadcastPlaylistCreated,
|
||||||
broadcastPlaylistDeleted,
|
broadcastPlaylistDeleted,
|
||||||
@@ -136,25 +138,16 @@ export function listPlaylistsByUser(
|
|||||||
requestingUserId: string | null,
|
requestingUserId: string | null,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: Playlist[]; total: number } {
|
): Page<Playlist> {
|
||||||
const isOwner = requestingUserId === userId;
|
const isOwner = requestingUserId === userId;
|
||||||
const offset = (page - 1) * limit;
|
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
|
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 = ? 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 ?;`;
|
: `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
|
const rows = db.prepare(sql).all(userId, limit + 1, offset);
|
||||||
| { count: number }
|
return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit);
|
||||||
| undefined;
|
|
||||||
const rows = db.prepare(sql).all(userId, limit, offset);
|
|
||||||
return {
|
|
||||||
items: rows.filter(isPlaylistRow).map(playlistRowToApi),
|
|
||||||
total: totalRow?.count ?? 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePlaylist(
|
export function updatePlaylist(
|
||||||
@@ -382,9 +375,11 @@ export function reorderPlaylist(
|
|||||||
const update = db.prepare(
|
const update = db.prepare(
|
||||||
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
||||||
);
|
);
|
||||||
|
transaction(() => {
|
||||||
for (let i = 0; i < order.length; i++) {
|
for (let i = 0; i < order.length; i++) {
|
||||||
update.run(i, playlist.id, order[i]);
|
update.run(i, playlist.id, order[i]);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { RichContent } from "../../model/interfaces.ts";
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.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/;
|
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||||
|
|
||||||
@@ -20,10 +24,11 @@ export const bandcampProvider: RichContentProvider = {
|
|||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (!contentType.startsWith("text/html")) {
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
await res.body?.cancel();
|
||||||
return { type: "bandcamp", siteName: "Bandcamp", url };
|
return { type: "bandcamp", siteName: "Bandcamp", url };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text();
|
const html = await readTextCapped(res);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "bandcamp",
|
type: "bandcamp",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
extractThemeColor,
|
extractThemeColor,
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
normalizeCssColor,
|
normalizeCssColor,
|
||||||
|
readTextCapped,
|
||||||
} from "../rich-content-service.ts";
|
} from "../rich-content-service.ts";
|
||||||
|
|
||||||
export const genericProvider: RichContentProvider = {
|
export const genericProvider: RichContentProvider = {
|
||||||
@@ -26,10 +27,11 @@ export const genericProvider: RichContentProvider = {
|
|||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (!contentType.startsWith("text/html")) {
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
await res.body?.cancel();
|
||||||
return { type: "generic", url };
|
return { type: "generic", url };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text();
|
const html = await readTextCapped(res);
|
||||||
const ld = extractJsonLd(html);
|
const ld = extractJsonLd(html);
|
||||||
|
|
||||||
// If og:url is present but points to a different page (e.g. the homepage),
|
// 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 { RichContent } from "../../model/interfaces.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.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 = {
|
export const soundcloudProvider: RichContentProvider = {
|
||||||
name: "soundcloud",
|
name: "soundcloud",
|
||||||
@@ -18,10 +22,11 @@ export const soundcloudProvider: RichContentProvider = {
|
|||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (!contentType.startsWith("text/html")) {
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
await res.body?.cancel();
|
||||||
return { type: "soundcloud", siteName: "SoundCloud", url };
|
return { type: "soundcloud", siteName: "SoundCloud", url };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text();
|
const html = await readTextCapped(res);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "soundcloud",
|
type: "soundcloud",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { RichContent } from "../../model/interfaces.ts";
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.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 {
|
function extractVideoId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -91,6 +95,7 @@ async function fetchOEmbed(
|
|||||||
};
|
};
|
||||||
return { title: data.title, thumbnailUrl: data.thumbnail_url };
|
return { title: data.title, thumbnailUrl: data.thumbnail_url };
|
||||||
}
|
}
|
||||||
|
await res.body?.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
// oembed failed — carry on
|
// oembed failed — carry on
|
||||||
}
|
}
|
||||||
@@ -121,9 +126,11 @@ export const youtubeProvider: RichContentProvider = {
|
|||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetchWithTimeout(url);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const html = await res.text();
|
const html = await readTextCapped(res);
|
||||||
title = extractOgTag(html, "title");
|
title = extractOgTag(html, "title");
|
||||||
thumbnailUrl = extractOgTag(html, "image");
|
thumbnailUrl = extractOgTag(html, "image");
|
||||||
|
} else {
|
||||||
|
await res.body?.cancel();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// scrape failed — carry on with undefined values
|
// scrape failed — carry on with undefined values
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ async function fetchViaCurl(
|
|||||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||||
"--max-time",
|
"--max-time",
|
||||||
String(Math.ceil(timeoutMs / 1000)),
|
String(Math.ceil(timeoutMs / 1000)),
|
||||||
|
"--max-filesize",
|
||||||
|
String(2 * MAX_PAGE_BYTES),
|
||||||
"--user-agent",
|
"--user-agent",
|
||||||
FETCH_HEADERS["User-Agent"],
|
FETCH_HEADERS["User-Agent"],
|
||||||
"--header",
|
"--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(
|
async function fetchOnce(
|
||||||
url: URL,
|
url: URL,
|
||||||
address: string,
|
address: string,
|
||||||
@@ -126,13 +199,15 @@ async function fetchOnce(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
return await fetch(url, {
|
res = await fetch(url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: FETCH_HEADERS,
|
headers: FETCH_HEADERS,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -145,9 +220,16 @@ async function fetchOnce(
|
|||||||
if (curlRes) return curlRes;
|
if (curlRes) return curlRes;
|
||||||
|
|
||||||
throw err;
|
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
|
* 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.
|
* 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,
|
url: string,
|
||||||
): Promise<FetchRichContentResult> {
|
): Promise<FetchRichContentResult> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||||
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||||
|
|
||||||
export interface VoteResult {
|
export interface VoteResult {
|
||||||
@@ -98,13 +99,9 @@ export function getDumpVoters(
|
|||||||
dumpId: string,
|
dumpId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): { items: User[]; total: number } {
|
): Page<User> {
|
||||||
const offset = (page - 1) * limit;
|
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(
|
const rawRows = db.prepare(
|
||||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
`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,
|
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
|
INNER JOIN votes v ON v.dump_id = ? AND v.user_id = u.id
|
||||||
ORDER BY v.created_at DESC
|
ORDER BY v.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(dumpId, limit, offset);
|
).all(dumpId, limit + 1, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isUserRow)) {
|
if (!rawRows.every(isUserRow)) {
|
||||||
throw new APIException(
|
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());
|
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 {
|
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
sendRaw(socket, JSON.stringify(data));
|
||||||
socket.send(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 {
|
export function sendToUser(userId: string, data: ServerToClientMessage): void {
|
||||||
for (const client of clients) {
|
broadcast(data, (client) => client.userId === userId);
|
||||||
if (client.userId === userId) {
|
|
||||||
send(client.socket, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectUser(userId: string): void {
|
export function disconnectUser(userId: string): void {
|
||||||
@@ -100,28 +112,19 @@ export function closeUserSockets(userId: string, reason: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPresence(): void {
|
export function broadcastPresence(): void {
|
||||||
const users = getOnlineUsers();
|
broadcast({ type: "presence_update", users: getOnlineUsers() });
|
||||||
for (const client of clients) {
|
|
||||||
send(client.socket, { type: "presence_update", users });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastNewDump(dump: Dump): void {
|
export function broadcastNewDump(dump: Dump): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "dump_created", dump });
|
||||||
send(client.socket, { type: "dump_created", dump });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastDumpUpdated(dump: Dump): void {
|
export function broadcastDumpUpdated(dump: Dump): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "dump_updated", dump });
|
||||||
send(client.socket, { type: "dump_updated", dump });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastDumpDeleted(dumpId: string): void {
|
export function broadcastDumpDeleted(dumpId: string): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "dump_deleted", dumpId });
|
||||||
send(client.socket, { type: "dump_deleted", dumpId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
|
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
|
||||||
@@ -132,16 +135,10 @@ export function broadcastVoteUpdate(
|
|||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
onlyUserId?: string,
|
onlyUserId?: string,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
broadcast(
|
||||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
{ type: "votes_update", dumpId, voteCount, voterId, action },
|
||||||
send(client.socket, {
|
onlyUserId ? (client) => client.userId === onlyUserId : undefined,
|
||||||
type: "votes_update",
|
);
|
||||||
dumpId,
|
|
||||||
voteCount,
|
|
||||||
voterId,
|
|
||||||
action,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
|
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
|
||||||
@@ -152,27 +149,20 @@ export function broadcastCommentLikeUpdate(
|
|||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
onlyUserId?: string,
|
onlyUserId?: string,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
broadcast(
|
||||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
{ type: "comment_likes_update", commentId, likeCount, likerId, action },
|
||||||
send(client.socket, {
|
onlyUserId ? (client) => client.userId === onlyUserId : undefined,
|
||||||
type: "comment_likes_update",
|
);
|
||||||
commentId,
|
|
||||||
likeCount,
|
|
||||||
likerId,
|
|
||||||
action,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToPlaylistAudience(
|
function sendToPlaylistAudience(
|
||||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||||
data: ServerToClientMessage,
|
data: ServerToClientMessage,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
broadcast(
|
||||||
if (playlist.isPublic || client.userId === playlist.userId) {
|
data,
|
||||||
send(client.socket, data);
|
(client) => playlist.isPublic || client.userId === playlist.userId,
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||||
@@ -224,38 +214,28 @@ export function broadcastPlaylistDumpsUpdated(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastUserUpdated(user: PublicUser): void {
|
export function broadcastUserUpdated(user: PublicUser): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "user_updated", user });
|
||||||
send(client.socket, { type: "user_updated", user });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastCommentCreated(comment: Comment): void {
|
export function broadcastCommentCreated(comment: Comment): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "comment_created", comment });
|
||||||
send(client.socket, { type: "comment_created", comment });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastCommentDeleted(
|
export function broadcastCommentDeleted(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "comment_deleted", commentId, dumpId });
|
||||||
send(client.socket, { type: "comment_deleted", commentId, dumpId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastCommentUpdated(comment: Comment): void {
|
export function broadcastCommentUpdated(comment: Comment): void {
|
||||||
for (const client of clients) {
|
broadcast({ type: "comment_updated", comment });
|
||||||
send(client.socket, { type: "comment_updated", comment });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global chat room for members: history is behind auth, so the live stream is
|
// Global chat room for members: history is behind auth, so the live stream is
|
||||||
// too — an anonymous socket gets nothing chat-related.
|
// too — an anonymous socket gets nothing chat-related.
|
||||||
function sendToMembers(data: ServerToClientMessage): void {
|
function sendToMembers(data: ServerToClientMessage): void {
|
||||||
for (const client of clients) {
|
broadcast(data, (client) => !!client.userId);
|
||||||
if (client.userId) send(client.socket, data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `nonce` is the sender's id for its pending copy; harmless to everyone else.
|
// `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
|
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
|
||||||
const PING_INTERVAL = 30_000;
|
const PING_INTERVAL = 30_000;
|
||||||
|
const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
@@ -360,6 +341,6 @@ setInterval(() => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
client.pongReceived = false;
|
client.pongReceived = false;
|
||||||
send(client.socket, { type: "ping" });
|
sendRaw(client.socket, PING);
|
||||||
}
|
}
|
||||||
}, PING_INTERVAL);
|
}, PING_INTERVAL);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { readTextCapped } from "./rich-content-service.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a YouTube playlist to its tracklist, at play time.
|
* Resolves a YouTube playlist to its tracklist, at play time.
|
||||||
@@ -183,7 +184,7 @@ export async function resolveYouTubePlaylist(
|
|||||||
`YouTube returned ${res.status}`,
|
`YouTube returned ${res.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
html = await res.text();
|
html = await readTextCapped(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof APIException) throw err;
|
if (err instanceof APIException) throw err;
|
||||||
throw new APIException(
|
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", {
|
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
|
||||||
auth: owner.auth,
|
auth: owner.auth,
|
||||||
})).data
|
})).data
|
||||||
.total,
|
.items.length,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
assertEquals((await get("/api/categories/nope/dumps")).status, 404);
|
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>>(
|
const likers = await get<PaginatedData<PublicUser>>(
|
||||||
`/api/comments/${commentId}/likers`,
|
`/api/comments/${commentId}/likers`,
|
||||||
);
|
);
|
||||||
assertEquals(likers.data.total, 2);
|
assertEquals(likers.data.items.length, 2);
|
||||||
assertFalse("email" in likers.data.items[0]);
|
assertFalse("email" in likers.data.items[0]);
|
||||||
|
|
||||||
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
|
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",
|
"d4",
|
||||||
"d3",
|
"d3",
|
||||||
]);
|
]);
|
||||||
assertEquals(page1.data.total, 5);
|
|
||||||
assertEquals(page1.data.hasMore, true);
|
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");
|
const page3 = await get<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
|
||||||
assertEquals(page3.data.items.length, 1);
|
assertEquals(page3.data.items.length, 1);
|
||||||
assertEquals(page3.data.hasMore, false);
|
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,
|
auth: owner.auth,
|
||||||
});
|
});
|
||||||
assertEquals(mine.data.items[0].title, "secret");
|
assertEquals(mine.data.items[0].title, "secret");
|
||||||
assertEquals(mine.data.total, 6);
|
assertEquals(mine.data.items.length, 6);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
|
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
|
||||||
.total,
|
.items.length,
|
||||||
5,
|
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 (?, ?, ?);`,
|
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||||
).run(id, voter.id, new Date().toISOString());
|
).run(id, voter.id, new Date().toISOString());
|
||||||
const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
|
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);
|
assertEquals(res.data.items[0].username, voter.username);
|
||||||
assertFalse("email" in res.data.items[0]);
|
assertFalse("email" in res.data.items[0]);
|
||||||
assertFalse("passwordHash" 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"]) {
|
for (const sub of ["dumps", "votes"]) {
|
||||||
const path = `/api/users/${owner.username}/${sub}`;
|
const path = `/api/users/${owner.username}/${sub}`;
|
||||||
const total = async (opts?: { auth: { Authorization: string } }) =>
|
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(), 1, sub);
|
||||||
assertEquals(await total({ auth: other.auth }), 1, sub);
|
assertEquals(await total({ auth: other.auth }), 1, sub);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { db } from "../model/db.ts";
|
|||||||
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
||||||
import { makeSlug } from "../lib/slugify.ts";
|
import { makeSlug } from "../lib/slugify.ts";
|
||||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||||
|
import { clearRichContentCache } from "../services/rich-content-service.ts";
|
||||||
|
import { clearImageCache } from "../routes/preview.ts";
|
||||||
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
|
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
|
||||||
|
|
||||||
export { app, db, TEST_DIR };
|
export { app, db, TEST_DIR };
|
||||||
@@ -358,6 +360,9 @@ export function stubNetwork(
|
|||||||
dns: Record<string, string[]> = {},
|
dns: Record<string, string[]> = {},
|
||||||
): { calls: URL[] } & Disposable {
|
): { calls: URL[] } & Disposable {
|
||||||
const calls: URL[] = [];
|
const calls: URL[] = [];
|
||||||
|
// A new network: metadata cached from the last one no longer describes it.
|
||||||
|
clearRichContentCache();
|
||||||
|
clearImageCache();
|
||||||
const fetchStub = stub(
|
const fetchStub = stub(
|
||||||
globalThis,
|
globalThis,
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
|
|||||||
.get() as {
|
.get() as {
|
||||||
n: number;
|
n: number;
|
||||||
};
|
};
|
||||||
assertEquals(applied.n, 15);
|
assertEquals(applied.n, 16);
|
||||||
// Running again applies nothing.
|
// Running again applies nothing.
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
|
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
|
||||||
n: number;
|
n: number;
|
||||||
}).n,
|
}).n,
|
||||||
15,
|
16,
|
||||||
);
|
);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ test("private playlists are the owner's alone", async () => {
|
|||||||
);
|
);
|
||||||
insertPlaylist({ userId: owner.id });
|
insertPlaylist({ userId: owner.id });
|
||||||
const path = `/api/users/${owner.username}/playlists`;
|
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(
|
assertEquals(
|
||||||
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.total,
|
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.items
|
||||||
|
.length,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
// Link previews: the HTML extractors, the providers, and fetchWithTimeout's
|
// Link previews: the HTML extractors, the providers, and fetchWithTimeout's
|
||||||
// guard on every hop. All network access is stubbed.
|
// 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 {
|
import {
|
||||||
assert,
|
assert,
|
||||||
assertEquals,
|
assertEquals,
|
||||||
@@ -23,6 +31,7 @@ import {
|
|||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
normalizeCssColor,
|
normalizeCssColor,
|
||||||
|
readTextCapped,
|
||||||
tryFetchRichContent,
|
tryFetchRichContent,
|
||||||
} from "../services/rich-content-service.ts";
|
} 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"));
|
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 () => {
|
Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => {
|
||||||
using net = stubNetwork((url) => {
|
using net = stubNetwork((url) => {
|
||||||
if (url.pathname === "/start") {
|
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(profile?.title, user.username);
|
||||||
assertEquals(net.calls.length, 0);
|
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", {
|
(await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
})).data,
|
})).data,
|
||||||
{ items: [], total: 0, hasMore: false },
|
{ items: [], hasMore: false },
|
||||||
);
|
);
|
||||||
assertEquals((await get("/api/follows/feed/users")).status, 401);
|
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,
|
auth: me.auth,
|
||||||
});
|
});
|
||||||
assertEquals(titles(feed), ["shared"]);
|
assertEquals(titles(feed), ["shared"]);
|
||||||
assertEquals(feed.data.total, 1);
|
assertEquals(feed.data.items.length, 1);
|
||||||
|
|
||||||
// A playlist gone private drops out of the feed.
|
// A playlist gone private drops out of the feed.
|
||||||
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
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", {
|
(await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
})).data
|
})).data
|
||||||
.total,
|
.items.length,
|
||||||
0,
|
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", {
|
const list = await get<PaginatedData<Notification>>("/api/notifications", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
});
|
});
|
||||||
assertEquals(list.data.total, 2);
|
assertEquals(list.data.items.length, 2);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
list.data.items.map((n) => n.type),
|
list.data.items.map((n) => n.type),
|
||||||
["dump_commented", "user_followed"],
|
["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");
|
assertEquals(res.headers.get("referrer-policy"), "no-referrer");
|
||||||
assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox");
|
assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox");
|
||||||
assertStringIncludes(res.headers.get("content-disposition")!, "inline;");
|
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 () => {
|
test("GET /api/files/:id: active content downloads instead of rendering", async () => {
|
||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
for (const mime of ["text/html", "image/svg+xml", "application/xml"]) {
|
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);
|
assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404);
|
||||||
const res = await get(`/api/files/${id}?token=${owner.mediaToken}`);
|
const res = await get(`/api/files/${id}?token=${owner.mediaToken}`);
|
||||||
assertEquals(res.status, 200);
|
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.
|
// A legacy session token still works in the query string.
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(await get(`/api/files/${id}?token=${owner.token}`)).status,
|
(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}`);
|
const res = await get(`/api/thumbnails/${video.id}`);
|
||||||
assertEquals(res.status, 200);
|
assertEquals(res.status, 200);
|
||||||
assertEquals(res.headers.get("content-type"), "image/jpeg");
|
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(
|
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",
|
"public, max-age=31536000, immutable",
|
||||||
);
|
);
|
||||||
assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404);
|
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.status, 200);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
own.headers.get("cache-control"),
|
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);
|
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 () => {
|
test("attachments: upload an image, serve it back", async () => {
|
||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
const res = await post<AttachmentResponse>("/api/attachments", {
|
const res = await post<AttachmentResponse>("/api/attachments", {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function Avatar(
|
|||||||
title={username}
|
title={username}
|
||||||
style={sizeStyle}
|
style={sizeStyle}
|
||||||
className="avatar-img"
|
className="avatar-img"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={() => setImgFailed(true)}
|
onError={() => setImgFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWSChat } from "../hooks/useWS.ts";
|
||||||
import { useChat } from "../hooks/useChat.ts";
|
import { useChat } from "../hooks/useChat.ts";
|
||||||
|
|
||||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||||
export function ChatButton() {
|
export function ChatButton() {
|
||||||
const { unreadChatCount } = useWS();
|
const { unreadChatCount } = useWSChat();
|
||||||
const { openChat } = useChat();
|
const { openChat } = useChat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWSChat } from "../hooks/useWS.ts";
|
||||||
import { useChat } from "../hooks/useChat.ts";
|
import { useChat } from "../hooks/useChat.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +12,7 @@ import { useChat } from "../hooks/useChat.ts";
|
|||||||
*/
|
*/
|
||||||
export function ChatFab() {
|
export function ChatFab() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { unreadChatCount } = useWS();
|
const { unreadChatCount } = useWSChat();
|
||||||
const { openChat } = useChat();
|
const { openChat } = useChat();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [headerHidden, setHeaderHidden] = useState(false);
|
const [headerHidden, setHeaderHidden] = useState(false);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Avatar } from "./Avatar.tsx";
|
|||||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||||
import { Tooltip } from "./Tooltip.tsx";
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
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 { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { can } from "../utils/permissions.ts";
|
import { can } from "../utils/permissions.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
@@ -553,8 +553,8 @@ type Row =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChatModal({ onClose }: { onClose: () => void }) {
|
export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { wsStatus, connectionEpoch } = useWS();
|
||||||
const {
|
const {
|
||||||
wsStatus,
|
|
||||||
chatMessages,
|
chatMessages,
|
||||||
deletedChatIds,
|
deletedChatIds,
|
||||||
pendingChatMessages,
|
pendingChatMessages,
|
||||||
@@ -571,8 +571,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
reconcileChat,
|
reconcileChat,
|
||||||
setChatTyping,
|
setChatTyping,
|
||||||
setChatOpen,
|
setChatOpen,
|
||||||
connectionEpoch,
|
} = useWSChat();
|
||||||
} = useWS();
|
|
||||||
const { authFetch, user } = useAuth();
|
const { authFetch, user } = useAuth();
|
||||||
const canModerate = can(user, "chat:moderate");
|
const canModerate = can(user, "chat:moderate");
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
@@ -25,7 +26,9 @@ interface DumpCardProps {
|
|||||||
isOwner?: boolean;
|
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 }:
|
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
|
||||||
DumpCardProps,
|
DumpCardProps,
|
||||||
) {
|
) {
|
||||||
@@ -139,4 +142,4 @@ export function DumpCard(
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { formatBytes } from "../utils/format.ts";
|
|||||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
|
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 {
|
import {
|
||||||
BAR_GAP,
|
BAR_GAP,
|
||||||
BAR_W,
|
BAR_W,
|
||||||
@@ -50,13 +54,15 @@ function filePlayerItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Waveform preview for the dump detail page — routes to global player,
|
// 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(
|
function AudioFilePreview(
|
||||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||||
) {
|
) {
|
||||||
const { mediaToken: token } = useAuth();
|
const { mediaToken: token } = useAuth();
|
||||||
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
const { current, playing, play, togglePlay, seekTo } = useContext(
|
||||||
useContext(PlayerContext);
|
PlayerContext,
|
||||||
|
);
|
||||||
|
const { currentTime, duration } = useContext(PlayerTimeContext);
|
||||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||||
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
|
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||||
const progress = isActive && duration > 0 ? currentTime / duration : 0;
|
const progress = isActive && duration > 0 ? currentTime / duration : 0;
|
||||||
@@ -159,6 +165,8 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
|
|||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
className="rich-content-compact-thumbnail"
|
className="rich-content-compact-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -186,11 +194,15 @@ export default function FilePreview(
|
|||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
|
// The server's preview (or the custom thumbnail): a card doesn't need
|
||||||
|
// the full upload.
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={thumbOverride ?? fileUrl}
|
src={dumpThumbnailUrl(dump, token)}
|
||||||
alt={dump.fileName}
|
alt={dump.fileName}
|
||||||
className="rich-content-compact-thumbnail"
|
className="rich-content-compact-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Plural, Trans } from "@lingui/react/macro";
|
import { Plural, Trans } from "@lingui/react/macro";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.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 { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||||
@@ -33,7 +34,8 @@ interface JournalCardProps {
|
|||||||
isOwner?: boolean;
|
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 }:
|
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||||
JournalCardProps,
|
JournalCardProps,
|
||||||
) {
|
) {
|
||||||
@@ -51,13 +53,13 @@ export function JournalCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||||
// still here too, rather than degrading to a text card with a 🎬.
|
// still here too, rather than degrading to a text card with a 🎬. An image
|
||||||
|
// shows the server's scaled-down preview, not the full upload.
|
||||||
const thumbnailUrl = dump.thumbnailMime
|
const thumbnailUrl = dump.thumbnailMime
|
||||||
? dumpThumbnailUrl(dump, mediaToken)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: dump.kind === "file"
|
: dump.kind === "file"
|
||||||
? (dump.fileMime?.startsWith("image/")
|
? (dump.fileMime?.startsWith("image/") ||
|
||||||
? dumpFileUrl(dump, mediaToken)
|
dump.fileMime?.startsWith("video/")
|
||||||
: dump.fileMime?.startsWith("video/")
|
|
||||||
? dumpThumbnailUrl(dump, mediaToken)
|
? dumpThumbnailUrl(dump, mediaToken)
|
||||||
: null)
|
: null)
|
||||||
: (dump.richContent?.thumbnailUrl ?? null);
|
: (dump.richContent?.thumbnailUrl ?? null);
|
||||||
@@ -224,4 +226,4 @@ export function JournalCard(
|
|||||||
{footer}
|
{footer}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -135,7 +136,9 @@ const INLINE_MARKDOWN_COMPONENTS: Components = {
|
|||||||
img: () => null,
|
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,
|
{ children, className, inline = false }: MarkdownProps,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -152,4 +155,4 @@ export function Markdown(
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function PlaylistCard(
|
|||||||
src={playlistImageUrl(playlist)}
|
src={playlistImageUrl(playlist)}
|
||||||
alt=""
|
alt=""
|
||||||
className="playlist-card-img"
|
className="playlist-card-img"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: <span className="playlist-card-icon">📋</span>}
|
: <span className="playlist-card-icon">📋</span>}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWSPresence } from "../hooks/useWS.ts";
|
||||||
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
|
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
|
||||||
|
|
||||||
// Past this many, the row ends in a "+N" counter rather than growing, so a
|
// 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(
|
export function PresenceRow(
|
||||||
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
|
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
|
||||||
) {
|
) {
|
||||||
const { onlineUsers } = useWS();
|
const { onlineUsers } = useWSPresence();
|
||||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||||
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function Thumbnail(
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
className={className}
|
className={className}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={() =>
|
onError={() =>
|
||||||
setAttempt(
|
setAttempt(
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ export interface PlayerContextValue {
|
|||||||
// Playback state — readable by any consumer
|
// Playback state — readable by any consumer
|
||||||
current: PlayerItem | null;
|
current: PlayerItem | null;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
|
|
||||||
// Queue. Every playback is a queue of at least one item, so single tracks and
|
// 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]`.
|
// albums share one code path. `current` is always `queue[queueIndex]`.
|
||||||
@@ -115,8 +113,6 @@ export interface PlayerContextValue {
|
|||||||
export const defaultPlayerValue: PlayerContextValue = {
|
export const defaultPlayerValue: PlayerContextValue = {
|
||||||
current: null,
|
current: null,
|
||||||
playing: false,
|
playing: false,
|
||||||
currentTime: 0,
|
|
||||||
duration: 0,
|
|
||||||
queue: [],
|
queue: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
@@ -143,3 +139,22 @@ export const defaultPlayerValue: PlayerContextValue = {
|
|||||||
export const PlayerContext = createContext<PlayerContextValue>(
|
export const PlayerContext = createContext<PlayerContextValue>(
|
||||||
defaultPlayerValue,
|
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,
|
PlayerContext,
|
||||||
type PlayerItem,
|
type PlayerItem,
|
||||||
playerItemKey,
|
playerItemKey,
|
||||||
|
PlayerTimeContext,
|
||||||
} from "./PlayerContext.ts";
|
} from "./PlayerContext.ts";
|
||||||
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
|
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -392,8 +393,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
current,
|
current,
|
||||||
playing,
|
playing,
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
queue,
|
queue,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
hasNext,
|
hasNext,
|
||||||
@@ -418,8 +417,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}), [
|
}), [
|
||||||
current,
|
current,
|
||||||
playing,
|
playing,
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
queue,
|
queue,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
hasNext,
|
hasNext,
|
||||||
@@ -441,9 +438,16 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
onError,
|
onError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const timeValue = useMemo(
|
||||||
|
() => ({ currentTime, duration }),
|
||||||
|
[currentTime, duration],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerContext.Provider value={value}>
|
<PlayerContext.Provider value={value}>
|
||||||
|
<PlayerTimeContext.Provider value={timeValue}>
|
||||||
{children}
|
{children}
|
||||||
|
</PlayerTimeContext.Provider>
|
||||||
</PlayerContext.Provider>
|
</PlayerContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type ColorScheme,
|
type ColorScheme,
|
||||||
type StyleName,
|
type StyleName,
|
||||||
@@ -37,15 +37,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
localStorage.setItem("color-scheme", colorScheme);
|
localStorage.setItem("color-scheme", colorScheme);
|
||||||
}, [colorScheme]);
|
}, [colorScheme]);
|
||||||
|
|
||||||
return (
|
const value = useMemo(() => ({
|
||||||
<ThemeContext.Provider
|
|
||||||
value={{
|
|
||||||
style,
|
style,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
setStyle: setStyleState,
|
setStyle: setStyleState,
|
||||||
setColorScheme: setColorSchemeState,
|
setColorScheme: setColorSchemeState,
|
||||||
}}
|
}), [style, colorScheme]);
|
||||||
>
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,10 +65,15 @@ export interface ChatError {
|
|||||||
message: string;
|
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 {
|
export interface WSContextValue {
|
||||||
wsStatus: "connecting" | "connected" | "disconnected";
|
wsStatus: "connecting" | "connected" | "disconnected";
|
||||||
wsErrorMessage: string | null;
|
wsErrorMessage: string | null;
|
||||||
onlineUsers: OnlineUser[];
|
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
myVotes: Set<string>;
|
myVotes: Set<string>;
|
||||||
likeCounts: Record<string, number>;
|
likeCounts: Record<string, number>;
|
||||||
@@ -84,6 +89,19 @@ export interface WSContextValue {
|
|||||||
lastUserEvent: UserEvent | null;
|
lastUserEvent: UserEvent | null;
|
||||||
unreadNotificationCount: number;
|
unreadNotificationCount: number;
|
||||||
lastNotification: Notification | null;
|
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). */
|
/** Live buffer of chat messages received this session (capped). */
|
||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
/** Ids of chat messages deleted live this session, so fetched history can
|
/** 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
|
/** Users currently composing a chat message (excludes nobody — the chatbox
|
||||||
* filters out the current user itself). Shaped like presence for avatars. */
|
* filters out the current user itself). Shaped like presence for avatars. */
|
||||||
typingUsers: OnlineUser[];
|
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;
|
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||||
retryChatMessage: (nonce: string) => void;
|
retryChatMessage: (nonce: string) => void;
|
||||||
discardChatMessage: (nonce: string) => void;
|
discardChatMessage: (nonce: string) => void;
|
||||||
@@ -129,11 +138,15 @@ export interface WSContextValue {
|
|||||||
setChatOpen: (open: boolean) => void;
|
setChatOpen: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who's connected. */
|
||||||
|
export interface WSPresenceContextValue {
|
||||||
|
onlineUsers: OnlineUser[];
|
||||||
|
}
|
||||||
|
|
||||||
/** What a consumer outside any provider gets. */
|
/** What a consumer outside any provider gets. */
|
||||||
export const defaultWSValue: WSContextValue = {
|
export const defaultWSValue: WSContextValue = {
|
||||||
wsStatus: "connecting",
|
wsStatus: "connecting",
|
||||||
wsErrorMessage: null,
|
wsErrorMessage: null,
|
||||||
onlineUsers: [],
|
|
||||||
voteCounts: {},
|
voteCounts: {},
|
||||||
myVotes: new Set(),
|
myVotes: new Set(),
|
||||||
likeCounts: {},
|
likeCounts: {},
|
||||||
@@ -149,13 +162,6 @@ export const defaultWSValue: WSContextValue = {
|
|||||||
lastUserEvent: null,
|
lastUserEvent: null,
|
||||||
unreadNotificationCount: 0,
|
unreadNotificationCount: 0,
|
||||||
lastNotification: null,
|
lastNotification: null,
|
||||||
chatMessages: [],
|
|
||||||
deletedChatIds: new Set(),
|
|
||||||
unreadChatCount: 0,
|
|
||||||
chatReadAt: null,
|
|
||||||
pendingChatMessages: [],
|
|
||||||
chatError: null,
|
|
||||||
typingUsers: [],
|
|
||||||
connectionEpoch: 0,
|
connectionEpoch: 0,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
@@ -163,6 +169,16 @@ export const defaultWSValue: WSContextValue = {
|
|||||||
removeCommentLike: () => {},
|
removeCommentLike: () => {},
|
||||||
injectDump: () => {},
|
injectDump: () => {},
|
||||||
clearUnreadNotifications: () => {},
|
clearUnreadNotifications: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultWSChatValue: WSChatContextValue = {
|
||||||
|
chatMessages: [],
|
||||||
|
deletedChatIds: new Set(),
|
||||||
|
unreadChatCount: 0,
|
||||||
|
chatReadAt: null,
|
||||||
|
pendingChatMessages: [],
|
||||||
|
chatError: null,
|
||||||
|
typingUsers: [],
|
||||||
sendChatMessage: () => {},
|
sendChatMessage: () => {},
|
||||||
retryChatMessage: () => {},
|
retryChatMessage: () => {},
|
||||||
discardChatMessage: () => {},
|
discardChatMessage: () => {},
|
||||||
@@ -175,4 +191,14 @@ export const defaultWSValue: WSContextValue = {
|
|||||||
setChatOpen: () => {},
|
setChatOpen: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defaultWSPresenceValue: WSPresenceContextValue = {
|
||||||
|
onlineUsers: [],
|
||||||
|
};
|
||||||
|
|
||||||
export const WSContext = createContext<WSContextValue>(defaultWSValue);
|
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 PlaylistEvent,
|
||||||
type UserEvent,
|
type UserEvent,
|
||||||
type VoteEvent,
|
type VoteEvent,
|
||||||
|
WSChatContext,
|
||||||
|
type WSChatContextValue,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
|
WSPresenceContext,
|
||||||
|
type WSPresenceContextValue,
|
||||||
} from "./WSContext.ts";
|
} from "./WSContext.ts";
|
||||||
import {
|
import {
|
||||||
type AvatarOverride,
|
type AvatarOverride,
|
||||||
@@ -1130,7 +1134,6 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
const value: WSContextValue = useMemo(() => ({
|
const value: WSContextValue = useMemo(() => ({
|
||||||
wsStatus,
|
wsStatus,
|
||||||
wsErrorMessage,
|
wsErrorMessage,
|
||||||
onlineUsers,
|
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
likeCounts,
|
likeCounts,
|
||||||
@@ -1146,13 +1149,6 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
lastUserEvent,
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
chatMessages,
|
|
||||||
deletedChatIds,
|
|
||||||
unreadChatCount,
|
|
||||||
chatReadAt,
|
|
||||||
pendingChatMessages,
|
|
||||||
chatError,
|
|
||||||
typingUsers,
|
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
@@ -1160,6 +1156,41 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
removeCommentLike,
|
removeCommentLike,
|
||||||
injectDump,
|
injectDump,
|
||||||
clearUnreadNotifications,
|
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,
|
sendChatMessage,
|
||||||
retryChatMessage,
|
retryChatMessage,
|
||||||
discardChatMessage,
|
discardChatMessage,
|
||||||
@@ -1171,24 +1202,6 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setChatTyping,
|
setChatTyping,
|
||||||
setChatOpen,
|
setChatOpen,
|
||||||
}), [
|
}), [
|
||||||
wsStatus,
|
|
||||||
wsErrorMessage,
|
|
||||||
onlineUsers,
|
|
||||||
voteCounts,
|
|
||||||
myVotes,
|
|
||||||
likeCounts,
|
|
||||||
myLikes,
|
|
||||||
recentDumps,
|
|
||||||
deletedDumpIds,
|
|
||||||
lastVoteEvent,
|
|
||||||
lastLikeEvent,
|
|
||||||
lastDumpEvent,
|
|
||||||
lastPlaylistEvent,
|
|
||||||
deletedPlaylistIds,
|
|
||||||
lastCommentEvent,
|
|
||||||
lastUserEvent,
|
|
||||||
unreadNotificationCount,
|
|
||||||
lastNotification,
|
|
||||||
chatMessages,
|
chatMessages,
|
||||||
deletedChatIds,
|
deletedChatIds,
|
||||||
unreadChatCount,
|
unreadChatCount,
|
||||||
@@ -1196,13 +1209,6 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
pendingChatMessages,
|
pendingChatMessages,
|
||||||
chatError,
|
chatError,
|
||||||
typingUsers,
|
typingUsers,
|
||||||
connectionEpoch,
|
|
||||||
castVote,
|
|
||||||
removeVote,
|
|
||||||
castCommentLike,
|
|
||||||
removeCommentLike,
|
|
||||||
injectDump,
|
|
||||||
clearUnreadNotifications,
|
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
retryChatMessage,
|
retryChatMessage,
|
||||||
discardChatMessage,
|
discardChatMessage,
|
||||||
@@ -1215,11 +1221,20 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setChatOpen,
|
setChatOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const presenceValue: WSPresenceContextValue = useMemo(
|
||||||
|
() => ({ onlineUsers }),
|
||||||
|
[onlineUsers],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WSContext.Provider value={value}>
|
<WSContext.Provider value={value}>
|
||||||
|
<WSChatContext.Provider value={chatValue}>
|
||||||
|
<WSPresenceContext.Provider value={presenceValue}>
|
||||||
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||||
{children}
|
{children}
|
||||||
</AvatarOverrideContext.Provider>
|
</AvatarOverrideContext.Provider>
|
||||||
|
</WSPresenceContext.Provider>
|
||||||
|
</WSChatContext.Provider>
|
||||||
</WSContext.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
|
* Calls `onSave(scrollY)` when the page is left — the component unmounts (a
|
||||||
* user scrolls, but only while `enabled` is true (e.g. the page is loaded).
|
* 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(
|
export function useScrollSave(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
onSave: (scrollY: number) => void,
|
onSave: (scrollY: number) => void,
|
||||||
) {
|
) {
|
||||||
|
const onSaveRef = useRef(onSave);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onSaveRef.current = onSave;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
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 = () => {
|
const onScroll = () => {
|
||||||
clearTimeout(timer);
|
y = globalThis.scrollY;
|
||||||
timer = setTimeout(() => onSave(globalThis.scrollY), 100);
|
|
||||||
};
|
};
|
||||||
|
const save = () => onSaveRef.current(y);
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
globalThis.addEventListener("pagehide", save);
|
||||||
return () => {
|
return () => {
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
clearTimeout(timer);
|
globalThis.removeEventListener("pagehide", save);
|
||||||
|
save();
|
||||||
};
|
};
|
||||||
}, [enabled, onSave]);
|
}, [enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { useContext } from "react";
|
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() {
|
export function useWS() {
|
||||||
return useContext(WSContext);
|
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]"
|
msgstr "[deleted]"
|
||||||
|
|
||||||
#. placeholder {0}: dump.commentCount
|
#. placeholder {0}: dump.commentCount
|
||||||
#: src/components/DumpCard.tsx:109
|
#: src/components/DumpCard.tsx:112
|
||||||
#: src/components/JournalCard.tsx:114
|
#: src/components/JournalCard.tsx:116
|
||||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
#. placeholder {0}: playlist.dumpCount
|
#. placeholder {0}: playlist.dumpCount
|
||||||
#: src/components/PlaylistCard.tsx:85
|
#: src/components/PlaylistCard.tsx:87
|
||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{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 {0}: typingNames[0]
|
||||||
#. placeholder {1}: typingNames[1]
|
#. placeholder {1}: typingNames[1]
|
||||||
#: src/components/ChatModal.tsx:1079
|
#: src/components/ChatModal.tsx:1078
|
||||||
msgid "{0} and {1} are typing…"
|
msgid "{0} and {1} are typing…"
|
||||||
msgstr "{0} and {1} are typing…"
|
msgstr "{0} and {1} are typing…"
|
||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#: src/components/ChatModal.tsx:1077
|
#: src/components/ChatModal.tsx:1076
|
||||||
msgid "{0} is typing…"
|
msgid "{0} is typing…"
|
||||||
msgstr "{0} is typing…"
|
msgstr "{0} is typing…"
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
|||||||
msgid "{label} ({count})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1208
|
#: src/components/ChatModal.tsx:1207
|
||||||
msgid "{unseenCount} new messages"
|
msgid "{unseenCount} new messages"
|
||||||
msgstr "{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>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<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"
|
msgid "1 new message"
|
||||||
msgstr "1 new message"
|
msgstr "1 new message"
|
||||||
|
|
||||||
@@ -220,8 +220,8 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Back to login"
|
msgstr "Back to login"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:328
|
#: src/contexts/WSProvider.tsx:332
|
||||||
#: src/contexts/WSProvider.tsx:711
|
#: src/contexts/WSProvider.tsx:715
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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"
|
msgid "Cancel removal"
|
||||||
msgstr "Cancel removal"
|
msgstr "Cancel removal"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:1267
|
||||||
#: src/components/ChatModal.tsx:1268
|
#: src/components/ChatModal.tsx:1268
|
||||||
#: src/components/ChatModal.tsx:1269
|
|
||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Cancel reply"
|
msgstr "Cancel reply"
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ msgstr "Change skin tone"
|
|||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:1168
|
#: src/components/ChatModal.tsx:1167
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Chat"
|
msgstr "Chat"
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ msgstr "Color scheme"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "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"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "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."
|
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."
|
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."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Couldn't load the chat."
|
msgstr "Couldn't load the chat."
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ msgstr "Delete dump"
|
|||||||
msgid "Delete failed ({0})"
|
msgid "Delete failed ({0})"
|
||||||
msgstr "Delete failed ({0})"
|
msgstr "Delete failed ({0})"
|
||||||
|
|
||||||
#: src/components/PlaylistCard.tsx:108
|
#: src/components/PlaylistCard.tsx:110
|
||||||
#: src/pages/PlaylistDetail.tsx:799
|
#: src/pages/PlaylistDetail.tsx:799
|
||||||
#: src/pages/UserPlaylists.tsx:483
|
#: src/pages/UserPlaylists.tsx:483
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
@@ -463,12 +463,12 @@ msgstr "Description (optional)"
|
|||||||
msgid "Discard"
|
msgid "Discard"
|
||||||
msgstr "Discard"
|
msgstr "Discard"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1172
|
#: src/components/ChatModal.tsx:1171
|
||||||
msgid "Disconnected — reconnecting…"
|
msgid "Disconnected — reconnecting…"
|
||||||
msgstr "Disconnected — reconnecting…"
|
msgstr "Disconnected — reconnecting…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:1243
|
||||||
#: src/components/ChatModal.tsx:1244
|
#: src/components/ChatModal.tsx:1244
|
||||||
#: src/components/ChatModal.tsx:1245
|
|
||||||
msgid "Dismiss"
|
msgid "Dismiss"
|
||||||
msgstr "Dismiss"
|
msgstr "Dismiss"
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ msgid "Done"
|
|||||||
msgstr "Done"
|
msgstr "Done"
|
||||||
|
|
||||||
#. placeholder {0}: dump.fileName
|
#. placeholder {0}: dump.fileName
|
||||||
#: src/components/FilePreview.tsx:317
|
#: src/components/FilePreview.tsx:329
|
||||||
msgid "Download {0}"
|
msgid "Download {0}"
|
||||||
msgstr "Download {0}"
|
msgstr "Download {0}"
|
||||||
|
|
||||||
@@ -804,7 +804,7 @@ msgstr "Link"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "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…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "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"
|
msgid "Load more"
|
||||||
msgstr "Load more"
|
msgstr "Load more"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1193
|
#: src/components/ChatModal.tsx:1192
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
@@ -827,7 +827,7 @@ msgstr "Load older messages"
|
|||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1086
|
#: src/components/ChatModal.tsx:1085
|
||||||
msgid "Loading messages…"
|
msgid "Loading messages…"
|
||||||
msgstr "Loading messages…"
|
msgstr "Loading messages…"
|
||||||
|
|
||||||
@@ -852,7 +852,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:1192
|
#: src/components/ChatModal.tsx:1191
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:669
|
#: src/components/TextEditor.tsx:669
|
||||||
#: src/components/UserListPopover.tsx:198
|
#: src/components/UserListPopover.tsx:198
|
||||||
@@ -926,7 +926,7 @@ msgstr "Name"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "new"
|
msgstr "new"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1121
|
#: src/components/ChatModal.tsx:1120
|
||||||
#: src/components/FeedTabBar.tsx:37
|
#: src/components/FeedTabBar.tsx:37
|
||||||
#: src/pages/UserPublicProfile.tsx:1350
|
#: src/pages/UserPublicProfile.tsx:1350
|
||||||
msgid "New"
|
msgid "New"
|
||||||
@@ -985,7 +985,7 @@ msgstr "No followed playlists yet."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1105
|
#: src/components/ChatModal.tsx:1104
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "No messages yet. Say hello!"
|
msgstr "No messages yet. Say hello!"
|
||||||
|
|
||||||
@@ -1003,7 +1003,7 @@ msgstr "No playlists match \"{q}\"."
|
|||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "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"
|
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"
|
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…"
|
msgid "Paste a link…"
|
||||||
msgstr "Paste a link…"
|
msgstr "Paste a link…"
|
||||||
|
|
||||||
#: src/components/FilePreview.tsx:108
|
#: src/components/FilePreview.tsx:114
|
||||||
#: src/components/FilePreview.tsx:269
|
#: src/components/FilePreview.tsx:281
|
||||||
#: src/components/MediaPlayer.tsx:484
|
#: src/components/MediaPlayer.tsx:484
|
||||||
#: src/components/RichContentCard.tsx:83
|
#: src/components/RichContentCard.tsx:83
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
@@ -1101,10 +1101,10 @@ msgstr "Pause"
|
|||||||
msgid "Picked up where you left off."
|
msgid "Picked up where you left off."
|
||||||
msgstr "Picked up where you left off."
|
msgstr "Picked up where you left off."
|
||||||
|
|
||||||
#: src/components/FilePreview.tsx:108
|
#: src/components/FilePreview.tsx:114
|
||||||
#: src/components/FilePreview.tsx:213
|
#: src/components/FilePreview.tsx:225
|
||||||
#: src/components/FilePreview.tsx:234
|
#: src/components/FilePreview.tsx:246
|
||||||
#: src/components/FilePreview.tsx:269
|
#: src/components/FilePreview.tsx:281
|
||||||
#: src/components/MediaPlayer.tsx:484
|
#: src/components/MediaPlayer.tsx:484
|
||||||
#: src/components/RichContentCard.tsx:83
|
#: src/components/RichContentCard.tsx:83
|
||||||
#: src/components/RichContentCard.tsx:129
|
#: src/components/RichContentCard.tsx:129
|
||||||
@@ -1148,9 +1148,9 @@ msgstr "Posting…"
|
|||||||
msgid "Previous track"
|
msgid "Previous track"
|
||||||
msgstr "Previous track"
|
msgstr "Previous track"
|
||||||
|
|
||||||
#: src/components/DumpCard.tsx:118
|
#: src/components/DumpCard.tsx:121
|
||||||
#: src/components/JournalCard.tsx:123
|
#: src/components/JournalCard.tsx:125
|
||||||
#: src/components/PlaylistCard.tsx:72
|
#: src/components/PlaylistCard.tsx:74
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:64
|
#: src/components/PlaylistMembershipPanel.tsx:64
|
||||||
#: src/pages/Dump.tsx:474
|
#: src/pages/Dump.tsx:474
|
||||||
#: src/pages/PlaylistDetail.tsx:681
|
#: src/pages/PlaylistDetail.tsx:681
|
||||||
@@ -1162,7 +1162,7 @@ msgstr "private"
|
|||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
|
|
||||||
#: src/components/PlaylistCard.tsx:71
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/pages/PlaylistDetail.tsx:680
|
#: src/pages/PlaylistDetail.tsx:680
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
@@ -1228,7 +1228,7 @@ msgstr "Replace file"
|
|||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Reply"
|
msgstr "Reply"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1256
|
#: src/components/ChatModal.tsx:1255
|
||||||
msgid "Replying to"
|
msgid "Replying to"
|
||||||
msgstr "Replying to"
|
msgstr "Replying to"
|
||||||
|
|
||||||
@@ -1315,7 +1315,7 @@ msgstr "Searching…"
|
|||||||
msgid "Seek"
|
msgid "Seek"
|
||||||
msgstr "Seek"
|
msgstr "Seek"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1296
|
#: src/components/ChatModal.tsx:1295
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Send"
|
msgstr "Send"
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ msgstr "Set new password"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1080
|
#: src/components/ChatModal.tsx:1079
|
||||||
msgid "Several people are typing…"
|
msgid "Several people are typing…"
|
||||||
msgstr "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/form/FormError.tsx:16
|
||||||
#: src/components/PageError.tsx:13
|
#: src/components/PageError.tsx:13
|
||||||
#: src/contexts/WSProvider.tsx:664
|
#: src/contexts/WSProvider.tsx:668
|
||||||
#: src/pages/PlaylistDetail.tsx:715
|
#: src/pages/PlaylistDetail.tsx:715
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
@@ -1436,11 +1436,11 @@ msgstr "Today"
|
|||||||
msgid "Track {0}"
|
msgid "Track {0}"
|
||||||
msgstr "Track {0}"
|
msgstr "Track {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1097
|
#: src/components/ChatModal.tsx:1096
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Try again"
|
msgstr "Try again"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1281
|
#: src/components/ChatModal.tsx:1280
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "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."
|
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."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "You're offline — not sent"
|
msgstr "You're offline — not sent"
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ msgid "[deleted]"
|
|||||||
msgstr "[supprimé]"
|
msgstr "[supprimé]"
|
||||||
|
|
||||||
#. placeholder {0}: dump.commentCount
|
#. placeholder {0}: dump.commentCount
|
||||||
#: src/components/DumpCard.tsx:109
|
#: src/components/DumpCard.tsx:112
|
||||||
#: src/components/JournalCard.tsx:114
|
#: src/components/JournalCard.tsx:116
|
||||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
#. placeholder {0}: playlist.dumpCount
|
#. placeholder {0}: playlist.dumpCount
|
||||||
#: src/components/PlaylistCard.tsx:85
|
#: src/components/PlaylistCard.tsx:87
|
||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
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 {0}: typingNames[0]
|
||||||
#. placeholder {1}: typingNames[1]
|
#. placeholder {1}: typingNames[1]
|
||||||
#: src/components/ChatModal.tsx:1079
|
#: src/components/ChatModal.tsx:1078
|
||||||
msgid "{0} and {1} are typing…"
|
msgid "{0} and {1} are typing…"
|
||||||
msgstr "{0} et {1} sont en train d'écrire…"
|
msgstr "{0} et {1} sont en train d'écrire…"
|
||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#: src/components/ChatModal.tsx:1077
|
#: src/components/ChatModal.tsx:1076
|
||||||
msgid "{0} is typing…"
|
msgid "{0} is typing…"
|
||||||
msgstr "{0} est en train d'écrire…"
|
msgstr "{0} est en train d'écrire…"
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
|||||||
msgid "{label} ({count})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1208
|
#: src/components/ChatModal.tsx:1207
|
||||||
msgid "{unseenCount} new messages"
|
msgid "{unseenCount} new messages"
|
||||||
msgstr "{unseenCount} nouveaux 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>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a été ajouté à <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"
|
msgid "1 new message"
|
||||||
msgstr "1 nouveau message"
|
msgstr "1 nouveau message"
|
||||||
|
|
||||||
@@ -220,8 +220,8 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Retour à la connexion"
|
msgstr "Retour à la connexion"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:328
|
#: src/contexts/WSProvider.tsx:332
|
||||||
#: src/contexts/WSProvider.tsx:711
|
#: src/contexts/WSProvider.tsx:715
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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"
|
msgid "Cancel removal"
|
||||||
msgstr "Annuler la suppression"
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:1267
|
||||||
#: src/components/ChatModal.tsx:1268
|
#: src/components/ChatModal.tsx:1268
|
||||||
#: src/components/ChatModal.tsx:1269
|
|
||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Annuler la réponse"
|
msgstr "Annuler la réponse"
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ msgstr "Changer la couleur de peau"
|
|||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:1168
|
#: src/components/ChatModal.tsx:1167
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Tribune"
|
msgstr "Tribune"
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ msgstr "Thème de couleur"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "Confirmer le nouveau mot de passe"
|
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"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
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."
|
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."
|
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."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Impossible de charger le chat."
|
msgstr "Impossible de charger le chat."
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ msgstr "Supprimer la reco"
|
|||||||
msgid "Delete failed ({0})"
|
msgid "Delete failed ({0})"
|
||||||
msgstr "Échec de la suppression ({0})"
|
msgstr "Échec de la suppression ({0})"
|
||||||
|
|
||||||
#: src/components/PlaylistCard.tsx:108
|
#: src/components/PlaylistCard.tsx:110
|
||||||
#: src/pages/PlaylistDetail.tsx:799
|
#: src/pages/PlaylistDetail.tsx:799
|
||||||
#: src/pages/UserPlaylists.tsx:483
|
#: src/pages/UserPlaylists.tsx:483
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
@@ -463,12 +463,12 @@ msgstr "Description (facultatif)"
|
|||||||
msgid "Discard"
|
msgid "Discard"
|
||||||
msgstr "Abandonner"
|
msgstr "Abandonner"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1172
|
#: src/components/ChatModal.tsx:1171
|
||||||
msgid "Disconnected — reconnecting…"
|
msgid "Disconnected — reconnecting…"
|
||||||
msgstr "Déconnecté — reconnexion…"
|
msgstr "Déconnecté — reconnexion…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:1243
|
||||||
#: src/components/ChatModal.tsx:1244
|
#: src/components/ChatModal.tsx:1244
|
||||||
#: src/components/ChatModal.tsx:1245
|
|
||||||
msgid "Dismiss"
|
msgid "Dismiss"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ msgid "Done"
|
|||||||
msgstr "Terminé"
|
msgstr "Terminé"
|
||||||
|
|
||||||
#. placeholder {0}: dump.fileName
|
#. placeholder {0}: dump.fileName
|
||||||
#: src/components/FilePreview.tsx:317
|
#: src/components/FilePreview.tsx:329
|
||||||
msgid "Download {0}"
|
msgid "Download {0}"
|
||||||
msgstr "Télécharger {0}"
|
msgstr "Télécharger {0}"
|
||||||
|
|
||||||
@@ -804,7 +804,7 @@ msgstr "Lien"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "Lien ou fichier"
|
msgstr "Lien ou fichier"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:710
|
#: src/contexts/WSProvider.tsx:714
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
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"
|
msgid "Load more"
|
||||||
msgstr "Charger plus"
|
msgstr "Charger plus"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1193
|
#: src/components/ChatModal.tsx:1192
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
@@ -827,7 +827,7 @@ msgstr "Charger les messages plus anciens"
|
|||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1086
|
#: src/components/ChatModal.tsx:1085
|
||||||
msgid "Loading messages…"
|
msgid "Loading messages…"
|
||||||
msgstr "Chargement des messages…"
|
msgstr "Chargement des messages…"
|
||||||
|
|
||||||
@@ -852,7 +852,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:1192
|
#: src/components/ChatModal.tsx:1191
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:669
|
#: src/components/TextEditor.tsx:669
|
||||||
#: src/components/UserListPopover.tsx:198
|
#: src/components/UserListPopover.tsx:198
|
||||||
@@ -926,7 +926,7 @@ msgstr "Nom"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "nouveau"
|
msgstr "nouveau"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1121
|
#: src/components/ChatModal.tsx:1120
|
||||||
#: src/components/FeedTabBar.tsx:37
|
#: src/components/FeedTabBar.tsx:37
|
||||||
#: src/pages/UserPublicProfile.tsx:1350
|
#: src/pages/UserPublicProfile.tsx:1350
|
||||||
msgid "New"
|
msgid "New"
|
||||||
@@ -985,7 +985,7 @@ msgstr "Pas encore de collections suivies."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1105
|
#: src/components/ChatModal.tsx:1104
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||||
|
|
||||||
@@ -1003,7 +1003,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
|
|||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
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"
|
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é"
|
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…"
|
msgid "Paste a link…"
|
||||||
msgstr "Collez un lien…"
|
msgstr "Collez un lien…"
|
||||||
|
|
||||||
#: src/components/FilePreview.tsx:108
|
#: src/components/FilePreview.tsx:114
|
||||||
#: src/components/FilePreview.tsx:269
|
#: src/components/FilePreview.tsx:281
|
||||||
#: src/components/MediaPlayer.tsx:484
|
#: src/components/MediaPlayer.tsx:484
|
||||||
#: src/components/RichContentCard.tsx:83
|
#: src/components/RichContentCard.tsx:83
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
@@ -1101,10 +1101,10 @@ msgstr "Pause"
|
|||||||
msgid "Picked up where you left off."
|
msgid "Picked up where you left off."
|
||||||
msgstr "Reprise de votre brouillon."
|
msgstr "Reprise de votre brouillon."
|
||||||
|
|
||||||
#: src/components/FilePreview.tsx:108
|
#: src/components/FilePreview.tsx:114
|
||||||
#: src/components/FilePreview.tsx:213
|
#: src/components/FilePreview.tsx:225
|
||||||
#: src/components/FilePreview.tsx:234
|
#: src/components/FilePreview.tsx:246
|
||||||
#: src/components/FilePreview.tsx:269
|
#: src/components/FilePreview.tsx:281
|
||||||
#: src/components/MediaPlayer.tsx:484
|
#: src/components/MediaPlayer.tsx:484
|
||||||
#: src/components/RichContentCard.tsx:83
|
#: src/components/RichContentCard.tsx:83
|
||||||
#: src/components/RichContentCard.tsx:129
|
#: src/components/RichContentCard.tsx:129
|
||||||
@@ -1148,9 +1148,9 @@ msgstr "Publication…"
|
|||||||
msgid "Previous track"
|
msgid "Previous track"
|
||||||
msgstr "Piste précédente"
|
msgstr "Piste précédente"
|
||||||
|
|
||||||
#: src/components/DumpCard.tsx:118
|
#: src/components/DumpCard.tsx:121
|
||||||
#: src/components/JournalCard.tsx:123
|
#: src/components/JournalCard.tsx:125
|
||||||
#: src/components/PlaylistCard.tsx:72
|
#: src/components/PlaylistCard.tsx:74
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:64
|
#: src/components/PlaylistMembershipPanel.tsx:64
|
||||||
#: src/pages/Dump.tsx:474
|
#: src/pages/Dump.tsx:474
|
||||||
#: src/pages/PlaylistDetail.tsx:681
|
#: src/pages/PlaylistDetail.tsx:681
|
||||||
@@ -1162,7 +1162,7 @@ msgstr "privé"
|
|||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
|
|
||||||
#: src/components/PlaylistCard.tsx:71
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/pages/PlaylistDetail.tsx:680
|
#: src/pages/PlaylistDetail.tsx:680
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
@@ -1228,7 +1228,7 @@ msgstr "Remplacer le fichier"
|
|||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Répondre"
|
msgstr "Répondre"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1256
|
#: src/components/ChatModal.tsx:1255
|
||||||
msgid "Replying to"
|
msgid "Replying to"
|
||||||
msgstr "En réponse à"
|
msgstr "En réponse à"
|
||||||
|
|
||||||
@@ -1315,7 +1315,7 @@ msgstr "Recherche…"
|
|||||||
msgid "Seek"
|
msgid "Seek"
|
||||||
msgstr "Position"
|
msgstr "Position"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1296
|
#: src/components/ChatModal.tsx:1295
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Envoyer"
|
msgstr "Envoyer"
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ msgstr "Définir un nouveau mot de passe"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1080
|
#: src/components/ChatModal.tsx:1079
|
||||||
msgid "Several people are typing…"
|
msgid "Several people are typing…"
|
||||||
msgstr "Plusieurs personnes écrivent…"
|
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/form/FormError.tsx:16
|
||||||
#: src/components/PageError.tsx:13
|
#: src/components/PageError.tsx:13
|
||||||
#: src/contexts/WSProvider.tsx:664
|
#: src/contexts/WSProvider.tsx:668
|
||||||
#: src/pages/PlaylistDetail.tsx:715
|
#: src/pages/PlaylistDetail.tsx:715
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
@@ -1436,11 +1436,11 @@ msgstr "Aujourd'hui"
|
|||||||
msgid "Track {0}"
|
msgid "Track {0}"
|
||||||
msgstr "Piste {0}"
|
msgstr "Piste {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1097
|
#: src/components/ChatModal.tsx:1096
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1281
|
#: src/components/ChatModal.tsx:1280
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Écrivez un 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."
|
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."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "Hors ligne — message non envoyé"
|
msgstr "Hors ligne — message non envoyé"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export interface PaginatedData<T> {
|
export interface PaginatedData<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
|
||||||
hasMore: boolean;
|
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
|
const votesData: PaginatedData<RawDump> = votesBody.success
|
||||||
? votesBody.data
|
? votesBody.data
|
||||||
: { items: [], total: 0, hasMore: false };
|
: { items: [], hasMore: false };
|
||||||
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
|
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
|
||||||
? playlistsBody.data
|
? playlistsBody.data
|
||||||
: { items: [], total: 0, hasMore: false };
|
: { items: [], hasMore: false };
|
||||||
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
|
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
|
||||||
? dumpsBody.data
|
? dumpsBody.data
|
||||||
: { items: [], total: 0, hasMore: false };
|
: { items: [], hasMore: false };
|
||||||
|
|
||||||
const profileUser = deserializePublicUser(userBody.data);
|
const profileUser = deserializePublicUser(userBody.data);
|
||||||
const voteItems = votesData.items.map(deserializeDump);
|
const voteItems = votesData.items.map(deserializeDump);
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { MediaPlayer } from "../../components/MediaPlayer.tsx";
|
|||||||
import FilePreview from "../../components/FilePreview.tsx";
|
import FilePreview from "../../components/FilePreview.tsx";
|
||||||
import { makeDump, makeSession } from "../fixtures.ts";
|
import { makeDump, makeSession } from "../fixtures.ts";
|
||||||
import { renderWithContexts } from "../render.tsx";
|
import { renderWithContexts } from "../render.tsx";
|
||||||
|
import {
|
||||||
|
dumpFileUrl,
|
||||||
|
dumpFileVersion,
|
||||||
|
dumpThumbnailUrl,
|
||||||
|
} from "../../utils/urls.ts";
|
||||||
|
|
||||||
function media(container: HTMLElement) {
|
function media(container: HTMLElement) {
|
||||||
return container.querySelector<HTMLMediaElement>("audio, video")!;
|
return container.querySelector<HTMLMediaElement>("audio, video")!;
|
||||||
@@ -221,13 +226,24 @@ describe("FilePreview", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("images show inline, with the media token", () => {
|
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(
|
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||||
"src",
|
"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", () => {
|
test("other files are a download link", () => {
|
||||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||||
@@ -253,12 +269,14 @@ describe("FilePreview", () => {
|
|||||||
|
|
||||||
test("compact video claims its generated still as artwork", async () => {
|
test("compact video claims its generated still as artwork", async () => {
|
||||||
const play = vi.fn();
|
const play = vi.fn();
|
||||||
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, {
|
const d = file("video/mp4");
|
||||||
|
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||||
player: { play },
|
player: { play },
|
||||||
});
|
});
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||||
expect(play.mock.calls[0][0].artworkUrl).toBe(
|
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||||
"http://localhost:8000/api/thumbnails/f1",
|
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,
|
playing: true,
|
||||||
current: {
|
current: {
|
||||||
kind: "file",
|
kind: "file",
|
||||||
fileUrl: "http://localhost:8000/api/files/f1?v=2048",
|
fileUrl: dumpFileUrl(d),
|
||||||
mimeType: "video/mp4",
|
mimeType: "video/mp4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { makeDump, makePlaylist } from "../fixtures.ts";
|
import { makeDump, makePlaylist } from "../fixtures.ts";
|
||||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||||
import { markPlaylistVisited } from "../../utils/visited.ts";
|
import { markPlaylistVisited } from "../../utils/visited.ts";
|
||||||
|
import { dumpThumbnailUrl } from "../../utils/urls.ts";
|
||||||
|
|
||||||
let player!: React.ContextType<typeof PlayerContext>;
|
let player!: React.ContextType<typeof PlayerContext>;
|
||||||
function Probe() {
|
function Probe() {
|
||||||
@@ -272,22 +273,19 @@ describe("JournalCard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a video file shows its generated still", () => {
|
test("a video file shows its generated still", () => {
|
||||||
const { container } = renderWithContexts(
|
const dump = makeDump({
|
||||||
<ul>
|
|
||||||
<JournalCard
|
|
||||||
dump={makeDump({
|
|
||||||
id: "v",
|
id: "v",
|
||||||
kind: "file",
|
kind: "file",
|
||||||
fileMime: "video/mp4",
|
fileMime: "video/mp4",
|
||||||
url: undefined,
|
url: undefined,
|
||||||
})}
|
});
|
||||||
shape="tall"
|
const { container } = renderWithContexts(
|
||||||
{...base}
|
<ul>
|
||||||
/>
|
<JournalCard dump={dump} shape="tall" {...base} />
|
||||||
</ul>,
|
</ul>,
|
||||||
);
|
);
|
||||||
expect(container.querySelector("img")!.getAttribute("src")).toBe(
|
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { type ReactNode, useContext, useState } from "react";
|
import { memo, type ReactNode, useContext, useState } from "react";
|
||||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||||
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
|
||||||
import {
|
import {
|
||||||
PlayerContext,
|
PlayerContext,
|
||||||
type PlayerItem,
|
type PlayerItem,
|
||||||
|
PlayerTimeContext,
|
||||||
} from "../../contexts/PlayerContext.ts";
|
} from "../../contexts/PlayerContext.ts";
|
||||||
import type { AuthResponse } from "../../model.ts";
|
import type { AuthResponse } from "../../model.ts";
|
||||||
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
|
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
|
||||||
@@ -64,7 +65,10 @@ function wrapperFor(initial: AuthResponse | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setup(session: AuthResponse | null = null) {
|
function setup(session: AuthResponse | null = null) {
|
||||||
const hook = renderHook(() => useContext(PlayerContext), {
|
const hook = renderHook(() => ({
|
||||||
|
...useContext(PlayerContext),
|
||||||
|
...useContext(PlayerTimeContext),
|
||||||
|
}), {
|
||||||
wrapper: wrapperFor(session),
|
wrapper: wrapperFor(session),
|
||||||
});
|
});
|
||||||
return { hook, p: () => hook.result.current };
|
return { hook, p: () => hook.result.current };
|
||||||
@@ -395,3 +399,41 @@ describe("expiring streams", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setSession = () => {};
|
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 { AuthProvider } from "../../contexts/AuthProvider.tsx";
|
||||||
import { WSProvider } from "../../contexts/WSProvider.tsx";
|
import { WSProvider } from "../../contexts/WSProvider.tsx";
|
||||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
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 { useAuth } from "../../hooks/useAuth.ts";
|
||||||
import { FakeWebSocket } from "../ws.ts";
|
import { FakeWebSocket } from "../ws.ts";
|
||||||
import { defaultRoutes } from "../render.tsx";
|
import { defaultRoutes } from "../render.tsx";
|
||||||
@@ -36,7 +36,7 @@ function setup({ signedIn = true } = {}) {
|
|||||||
defaultRoutes({ session: signedIn ? me : null });
|
defaultRoutes({ session: signedIn ? me : null });
|
||||||
const hook = renderHook(
|
const hook = renderHook(
|
||||||
() => ({
|
() => ({
|
||||||
ws: useWS(),
|
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
|
||||||
auth: useAuth(),
|
auth: useAuth(),
|
||||||
avatars: useContext(AvatarOverrideContext),
|
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", () => {
|
describe("useScrollSave", () => {
|
||||||
test("debounces scroll positions while enabled", () => {
|
const scrollTo = (y: number) => {
|
||||||
vi.useFakeTimers();
|
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 onSave = vi.fn();
|
||||||
const { rerender, unmount } = renderHook(
|
const { unmount } = renderHook(() => useScrollSave(true, onSave));
|
||||||
({ on }) => useScrollSave(on, onSave),
|
scrollTo(100);
|
||||||
{ initialProps: { on: true } },
|
scrollTo(250);
|
||||||
);
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
globalThis.dispatchEvent(new Event("scroll"));
|
// The next page has already scrolled back up by the time this unmounts.
|
||||||
globalThis.dispatchEvent(new Event("scroll"));
|
Object.defineProperty(globalThis, "scrollY", {
|
||||||
vi.advanceTimersByTime(100);
|
value: 0,
|
||||||
expect(onSave).toHaveBeenCalledTimes(1);
|
configurable: true,
|
||||||
rerender({ on: false });
|
});
|
||||||
globalThis.dispatchEvent(new Event("scroll"));
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
expect(onSave).toHaveBeenCalledTimes(1);
|
|
||||||
unmount();
|
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 { AuthContext } from "../contexts/AuthContext.ts";
|
||||||
import { AuthProvider } from "../contexts/AuthProvider.tsx";
|
import { AuthProvider } from "../contexts/AuthProvider.tsx";
|
||||||
import {
|
import {
|
||||||
|
defaultWSChatValue,
|
||||||
|
defaultWSPresenceValue,
|
||||||
defaultWSValue,
|
defaultWSValue,
|
||||||
|
WSChatContext,
|
||||||
|
type WSChatContextValue,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
|
WSPresenceContext,
|
||||||
|
type WSPresenceContextValue,
|
||||||
} from "../contexts/WSContext.ts";
|
} from "../contexts/WSContext.ts";
|
||||||
import { WSProvider } from "../contexts/WSProvider.tsx";
|
import { WSProvider } from "../contexts/WSProvider.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -25,9 +31,12 @@ import {
|
|||||||
} from "../contexts/CategoriesContext.ts";
|
} from "../contexts/CategoriesContext.ts";
|
||||||
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
|
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
|
||||||
import {
|
import {
|
||||||
|
defaultPlayerTimeValue,
|
||||||
defaultPlayerValue,
|
defaultPlayerValue,
|
||||||
PlayerContext,
|
PlayerContext,
|
||||||
type PlayerContextValue,
|
type PlayerContextValue,
|
||||||
|
PlayerTimeContext,
|
||||||
|
type PlayerTimeValue,
|
||||||
} from "../contexts/PlayerContext.ts";
|
} from "../contexts/PlayerContext.ts";
|
||||||
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
|
||||||
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
|
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
|
||||||
@@ -84,10 +93,11 @@ function Router(
|
|||||||
|
|
||||||
export interface StubOptions extends RouterOptions {
|
export interface StubOptions extends RouterOptions {
|
||||||
session?: AuthResponse | null;
|
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>;
|
follows?: Partial<FollowContextValue>;
|
||||||
categories?: Category[] | Partial<CategoriesContextValue>;
|
categories?: Category[] | Partial<CategoriesContextValue>;
|
||||||
player?: Partial<PlayerContextValue>;
|
player?: Partial<PlayerContextValue & PlayerTimeValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StubAuth(
|
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(
|
function categoriesValue(
|
||||||
c: StubOptions["categories"],
|
c: StubOptions["categories"],
|
||||||
): CategoriesContextValue {
|
): CategoriesContextValue {
|
||||||
@@ -130,9 +151,25 @@ export function renderWithContexts(
|
|||||||
<PlayerContext.Provider
|
<PlayerContext.Provider
|
||||||
value={{ ...defaultPlayerValue, ...opts.player }}
|
value={{ ...defaultPlayerValue, ...opts.player }}
|
||||||
>
|
>
|
||||||
<WSContext.Provider value={{ ...defaultWSValue, ...opts.ws }}>
|
<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
|
<FollowContext.Provider
|
||||||
value={{ ...defaultFollowValue, isLoaded: true, ...opts.follows }}
|
value={{
|
||||||
|
...defaultFollowValue,
|
||||||
|
isLoaded: true,
|
||||||
|
...opts.follows,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CategoriesContext.Provider
|
<CategoriesContext.Provider
|
||||||
value={categoriesValue(opts.categories)}
|
value={categoriesValue(opts.categories)}
|
||||||
@@ -140,7 +177,10 @@ export function renderWithContexts(
|
|||||||
<Router {...opts}>{children}</Router>
|
<Router {...opts}>{children}</Router>
|
||||||
</CategoriesContext.Provider>
|
</CategoriesContext.Provider>
|
||||||
</FollowContext.Provider>
|
</FollowContext.Provider>
|
||||||
|
</WSPresenceContext.Provider>
|
||||||
|
</WSChatContext.Provider>
|
||||||
</WSContext.Provider>
|
</WSContext.Provider>
|
||||||
|
</PlayerTimeContext.Provider>
|
||||||
</PlayerContext.Provider>
|
</PlayerContext.Provider>
|
||||||
</StubAuth>
|
</StubAuth>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -141,12 +141,17 @@ describe("thumbnail tint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("urls", () => {
|
describe("urls", () => {
|
||||||
test("dumpFileUrl versions by size and carries the token", () => {
|
test("dumpFileUrl versions by size and last change, and carries the token", () => {
|
||||||
expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe(
|
const createdAt = new Date(1000);
|
||||||
`${API_URL}/api/files/d1?v=42`,
|
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(
|
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", () => {
|
test("dumpThumbnailUrl", () => {
|
||||||
@@ -154,6 +159,9 @@ describe("urls", () => {
|
|||||||
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
|
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
|
||||||
`${API_URL}/api/thumbnails/d1?token=tk`,
|
`${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", () => {
|
test("normalizeUrl adds a scheme when there's none", () => {
|
||||||
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
|
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { API_URL } from "../config/api.ts";
|
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
|
* 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`
|
* 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=`).
|
* the WebSocket connection (`?token=`).
|
||||||
*/
|
*/
|
||||||
export function dumpFileUrl(
|
export function dumpFileUrl(
|
||||||
dump: { id: string; fileSize?: number },
|
dump: VersionedDump,
|
||||||
token?: string | null,
|
token?: string | null,
|
||||||
): string {
|
): string {
|
||||||
const auth = token ? `&token=${encodeURIComponent(token)}` : "";
|
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(
|
export function dumpThumbnailUrl(
|
||||||
dump: { id: string },
|
dump: VersionedDump,
|
||||||
token?: string | null,
|
token?: string | null,
|
||||||
): string {
|
): string {
|
||||||
const auth = token ? `?token=${encodeURIComponent(token)}` : "";
|
const params = [
|
||||||
return `${API_URL}/api/thumbnails/${dump.id}${auth}`;
|
...(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 {
|
export function normalizeUrl(input: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user