Compare commits

...

4 Commits

Author SHA1 Message Date
khannurien
d392be7cb8 v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:19:48 +00:00
khannurien
e9f6a1f23e v3: deno check src/ passes again — the tests import userEvent by name — 71 errors, all the same one: @testing-library/user-event 14.6.7 ships ESM-syntax declarations without "type": "module", so Deno reads them as CommonJS and the default import is the module namespace, on which setup() and click() don't exist. Vite resolves it the other way, which is why the suite still ran. The package exports userEvent by name as well; the 40 test files that imported it use that now. No behaviour changes; the 627 tests pass and the lint is clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:49:49 +00:00
khannurien
2b6e68391b v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:44:14 +00:00
khannurien
07d0344be7 v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:30:28 +00:00
452 changed files with 14149 additions and 14101 deletions

View File

@@ -10,6 +10,7 @@ RUN deno install
COPY index.html ./
COPY public/ ./public/
COPY src/ ./src/
COPY shared/ ./shared/
# VITE_API_* are only needed when the API lives on a different host than the frontend.
ARG VITE_API_PROTOCOL
@@ -32,6 +33,7 @@ COPY deno.json deno.lock ./
RUN deno install
COPY api/ ./api/
COPY shared/ ./shared/
COPY --from=builder /app/dist/ ./dist/
# Persistent data: database and uploaded/generated files must be mounted as volumes.

View File

@@ -105,35 +105,63 @@ example.com {
## Project structure
```
shared/ # Imported by both halves: API and socket types, limits, permissions
test/ # Tests of shared/ (Deno): test/<module>.test.ts
api/
main.ts # Entry point — Oak application, middleware, routes
config.ts # Environment variables
middleware/ # errorMiddleware, authMiddleware
routes/ # HTTP routes + WebSocket
services/ # Business logic
middleware/ # Oak middleware: errors, auth, OG tags
routes/ # One router per URL prefix: HTTP + WebSocket, no SQL
services/ # Business logic and every SQL query
remote/ # Everything that fetches from other sites
providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
model/ # Row types, type guards
lib/ # JWT, pagination, slugify, upload, static helpers, …
model/ # Per table: row type, row guard, row ↔ API mappers
validators/ # Per domain: request body guards
lib/ # Helpers with no SQL and no outbound fetch: JWT, slugs, files, …
db/
connection.ts # The database handle and transaction()
startup.ts # Housekeeping run when the database opens
schema.sql # Database schema
init.ts # First-run database initialisation
migrate.ts # Migration runner
migrations/ # Versioned schema migrations
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/
gerbeur.db # SQLite database (not committed)
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/ # React frontend (Vite)
config/ # API base URL, validation constants, feed tabs, upload limits
pages/ # Route-level components
index/ # Feed views (hot, new, followed, journal)
components/ # Shared UI components
form/ # Reusable form controls
contexts/ # Auth, WebSocket, player, follows, theme
hooks/ # Data fetching and UI hooks
utils/ # Formatting, URL, hot-score, waveform, … helpers
config/ # API base URL, feed tabs, player mode
pages/ # One component per route, nothing else
components/ # React components only (.tsx)
contexts/ # FooContext.ts + FooProvider.tsx pairs
hooks/ # Every hook (useFoo.ts)
utils/ # Every non-React module: pure helpers, storage, API glue
test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
model.ts # Shared frontend types
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker)
```
Whatever crosses the wire is defined once, in `shared/`: the API envelope
(`shared/api.ts`), each domain's types and request bodies (`shared/dump.ts`,
`shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation
limits and the role permissions. The server sends Dates; the client reads
`Wire<T>`, the same type with every Date an ISO string, and turns it back with
`src/utils/shared/deserialize.ts`. `shared/` imports nothing from either half.
The backend is split by layer, one kind of module per folder, and each file is
named after its domain: the dumps are `routes/dumps.ts`,
`services/dump-service.ts`, `model/dump.ts` and `validators/dump.ts`. Routes
parse and answer, services hold the logic and the SQL, `remote/` is the only
place that fetches from other sites. Tests mirror the source one file per
module, so the tests of `services/chat-service.ts` are in
`test/services/chat-service.test.ts`.
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
split into the same domain folders — `auth`, `category`, `chat`, `comment`,
`dump`, `editor`, `feed`, `form`, `layout`, `player`, `playlist`, `profile`,
`social`, `theme`, `ws` — plus `shared/` for what belongs to no single domain.
So the chat lives in `components/chat/`, `contexts/chat/`, `hooks/chat/` and
`utils/chat/`, and its tests in `test/<same path>`. Files are imported directly
(no `index.ts` barrels), and everything uses named exports.

View File

@@ -48,7 +48,6 @@ export const ALLOWED_IMAGE_MIMES = new Set([
"image/webp",
]);
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
export const DUMP_ALLOWED_MIME_PREFIXES = [
"text/",
"image/",
@@ -76,22 +75,6 @@ export const PAGINATION_MAX_LIMIT = 100;
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
export const UNUSED_INVITES_RETENTION_DAYS = 7;
// Validation constraints
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,
PASSWORD_MIN: 8,
PASSWORD_MAX: 128,
DUMP_TITLE_MAX: 200,
DUMP_COMMENT_MAX: 5000,
PLAYLIST_TITLE_MAX: 100,
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
CATEGORY_NAME_MAX: 50,
CATEGORY_SLUG_MAX: 50,
} as const;
// SEO/OG
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";

36
api/db/connection.ts Normal file
View File

@@ -0,0 +1,36 @@
import { DatabaseSync } from "node:sqlite";
import { DB_PATH } from "../config.ts";
import { runMigrations } from "./migrate.ts";
import { runStartupTasks } from "./startup.ts";
export const db = new DatabaseSync(DB_PATH);
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a
// commit no longer waits on an fsync (a crash can lose the last transactions,
// never corrupt the file). busy_timeout covers the init/migration scripts and
// the test harness opening the same file.
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
`);
runMigrations(db);
await runStartupTasks(db);
/**
* Runs `fn` inside one transaction: several writes commit together (and cost
* one commit rather than one each), and a throw rolls them all back. Nested
* calls join the outer transaction.
*/
export function transaction<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;
}
}

52
api/db/startup.ts Normal file
View File

@@ -0,0 +1,52 @@
import { randomBytes, scryptSync } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import {
ATTACHMENTS_DIR,
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
/**
* Housekeeping run once when the database is opened, after the migrations:
* drops what has expired, and seeds the default admin on an empty instance.
*/
export async function runStartupTasks(db: DatabaseSync): Promise<void> {
// Purge expired/used password reset tokens
db.prepare(
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
).run();
// Purge expired unused invites
db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
).run();
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
const orphanedAttachments = db.prepare(
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).all() as { id: string }[];
if (orphanedAttachments.length > 0) {
for (const { id } of orphanedAttachments) {
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
}
db.prepare(
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).run();
}
// Create default admin user if no users exist
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
count: number;
};
if (userCount.count === 0) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`;
db.prepare(
`INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`,
).run(crypto.randomUUID(), passwordHash);
console.log(
"Created default admin user (username: admin, password: admin)",
);
}
}

13
api/lib/api-exception.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { APIErrorCode } from "../../shared/api.ts";
/** Thrown anywhere below a route; errorMiddleware turns it into a response. */
export class APIException extends Error {
readonly code: APIErrorCode;
readonly status: number;
constructor(code: APIErrorCode, status: number, message: string) {
super(message);
this.code = code;
this.status = status;
}
}

View File

@@ -1,12 +1,10 @@
import type { Context } from "@oak/oak";
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
import {
APIErrorCode,
APIException,
type AuthPayload,
type Role,
} from "../model/interfaces.ts";
import { db } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "./api-exception.ts";
import type { AuthPayload } from "../model/auth.ts";
import type { Role } from "../../shared/user.ts";
import { getSessionState } from "../services/user-service.ts";
/**
* Checks a signature-verified token against the live user row: the user must
@@ -21,19 +19,15 @@ function checkTokenOwner(
userId: string,
iat: number | undefined,
): { role: Role } | null {
const row = db.prepare(
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
).get(userId) as
| { role: Role; tokens_valid_after: string | null }
| undefined;
if (!row) return null;
if (row.tokens_valid_after) {
const state = getSessionState(userId);
if (!state) return null;
if (state.tokensValidAfter) {
const cutoff = Math.floor(
new Date(row.tokens_valid_after).getTime() / 1000,
new Date(state.tokensValidAfter).getTime() / 1000,
);
if ((iat ?? 0) < cutoff) return null;
}
return { role: row.role };
return { role: state.role };
}
/**

View File

@@ -7,7 +7,7 @@ import {
isAuthPayload,
isPasswordResetPayload,
type PasswordResetPayload,
} from "../model/interfaces.ts";
} from "../model/auth.ts";
import { JWT_SECRET } from "../config.ts";
if (!JWT_SECRET) {

View File

@@ -1,40 +1,5 @@
import { type AuthPayload, type Role } from "../model/interfaces.ts";
// Named capabilities checked at enforcement points. Extend this union as new
// gated capabilities are added (e.g. "invite:manage").
export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "chat:moderate"
| "user:manage"
| "category:manage";
const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
"user:manage",
"category:manage",
];
// Roles are fixed in code. `admin` is a superset of every permission (current
// and future); `moderator` gets the content-moderation permissions only.
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
moderator: [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
],
user: [],
};
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role].includes(permission);
}
import { hasPermission, type Permission } from "../../shared/permissions.ts";
import type { AuthPayload } from "../model/auth.ts";
export function can(payload: AuthPayload, permission: Permission): boolean {
return hasPermission(payload.role, permission);

View File

@@ -1,4 +1,4 @@
import type { Playlist } from "../model/interfaces.ts";
import type { Playlist } from "../../shared/playlist.ts";
/** A playlist's dump, with what deciding who may see it takes. */
export interface PlaylistMember {

View File

@@ -7,9 +7,8 @@
// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
// prod, public/ in dev) — see that script for how the output dir is chosen.
import { initWasm, Resvg } from "@resvg/resvg-wasm";
import { fetchTwemojiSvg } from "../remote/twemoji.ts";
// Pinned Twemoji release.
const TWEMOJI_VERSION = "16.0.1";
const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
const APPLE_SIZE = 180;
const ICO_SIZES = [16, 32, 48];
@@ -35,34 +34,6 @@ function ensureWasm(): Promise<unknown> {
return wasmReady;
}
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
const glyphCache = new Map<string, string>();
async function fetchGlyph(emoji: string): Promise<string> {
const cp = emojiToCodepoint(emoji);
const cached = glyphCache.get(cp);
if (cached) return cached;
const url =
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
let res: Response;
try {
res = await fetch(url);
} catch (cause) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
{ cause },
);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`,
);
}
const svg = await res.text();
glyphCache.set(cp, svg);
return svg;
}
function renderPng(svg: string, size: number, background?: string): Uint8Array {
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: size },
@@ -143,7 +114,7 @@ export async function generateSiteIcons(
opts: GenerateSiteIconsOptions,
): Promise<void> {
const { emoji, outDir, siteName, bgColor } = opts;
const svg = await fetchGlyph(emoji);
const svg = await fetchTwemojiSvg(emoji, emojiToCodepoint(emoji));
await ensureWasm();
await Deno.mkdir(outDir, { recursive: true });

View File

@@ -1,5 +1,6 @@
import type { Context } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "./api-exception.ts";
import { serveFile } from "./serve-file.ts";
import {
ALLOWED_IMAGE_MIMES,

View File

@@ -5,12 +5,11 @@ import {
type State,
} from "@oak/oak";
import { authenticate } from "../lib/auth.ts";
import {
APIErrorCode,
APIException,
type AuthPayload,
} from "../model/interfaces.ts";
import { can, type Permission } from "../lib/permissions.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { AuthPayload } from "../model/auth.ts";
import { can } from "../lib/permissions.ts";
import type { Permission } from "../../shared/permissions.ts";
export interface AuthState extends State {
user: AuthPayload;

View File

@@ -1,6 +1,7 @@
import { Context, Next } from "@oak/oak";
import { APIErrorCode, APIException, APIFailure } from "../model/interfaces.ts";
import { APIErrorCode, APIFailure } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
export async function errorMiddleware(ctx: Context, next: Next) {
try {

36
api/model/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import { isRole, type Role } from "../../shared/user.ts";
export interface AuthPayload {
userId: string;
username: string;
role: Role;
exp: number;
iat?: number;
}
export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj &&
typeof obj === "object" &&
// Purpose-scoped tokens (password reset, media) are never session tokens.
!("purpose" in obj) &&
"userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" &&
"role" in obj && isRole(obj.role) &&
"exp" in obj && typeof obj.exp === "number";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;
exp: number;
}
export function isPasswordResetPayload(
obj: unknown,
): obj is PasswordResetPayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj &&
(obj as Record<string, unknown>).purpose === "password-reset" &&
"userId" in obj &&
typeof (obj as Record<string, unknown>).userId === "string";
}

34
api/model/category.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { SQLOutputValue } from "node:sqlite";
import type { Category } from "../../shared/category.ts";
export interface CategoryRow {
id: string;
slug: string;
name: string;
position: number;
created_at: string;
updated_at: string | null;
[key: string]: SQLOutputValue;
}
export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"slug" in obj && typeof obj.slug === "string" &&
"name" in obj && typeof obj.name === "string" &&
"position" in obj && typeof obj.position === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null);
}
export function categoryRowToApi(row: CategoryRow): Category {
return {
id: row.id,
slug: row.slug,
name: row.name,
position: row.position,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
};
}

54
api/model/chat.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { SQLOutputValue } from "node:sqlite";
import type { ChatMessage } from "../../shared/chat.ts";
export interface ChatMessageRow {
id: string;
user_id: string;
body: string;
created_at: string;
updated_at: string | null;
author_username: string;
author_avatar_mime: string | null;
// Reply columns: reply_to_id is the stored target; the author/body preview are
// LEFT-JOINed from the target and go null when there's no reply (or it was
// since deleted).
reply_to_id: string | null;
reply_to_author: string | null;
reply_to_body: string | null;
[key: string]: SQLOutputValue;
}
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null) &&
"reply_to_id" in obj &&
(typeof obj.reply_to_id === "string" || obj.reply_to_id === null) &&
"reply_to_author" in obj &&
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
"reply_to_body" in obj &&
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
}
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
return {
id: row.id,
userId: row.user_id,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
replyToId: row.reply_to_id ?? undefined,
replyToAuthor: row.reply_to_author ?? undefined,
replyToBody: row.reply_to_body ?? undefined,
};
}

54
api/model/comment.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { SQLOutputValue } from "node:sqlite";
import type { Comment } from "../../shared/comment.ts";
export interface CommentRow {
id: string;
dump_id: string;
user_id: string;
parent_id: string | null;
body: string;
created_at: string;
updated_at: string | null;
deleted: number;
like_count: number;
author_username: string;
author_avatar_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isCommentRow(
obj: unknown,
): obj is CommentRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"dump_id" in obj && typeof obj.dump_id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"parent_id" in obj &&
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"deleted" in obj && typeof obj.deleted === "number" &&
"like_count" in obj && typeof obj.like_count === "number" &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
export function commentRowToApi(row: CommentRow): Comment {
return {
id: row.id,
dumpId: row.dump_id,
userId: row.user_id,
parentId: row.parent_id ?? undefined,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted),
likeCount: row.like_count,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
};
}

View File

@@ -1,571 +0,0 @@
import { randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import {
type Category,
type ChatMessage,
type Comment,
Dump,
isRole,
type Notification,
type NotificationData,
type NotificationType,
type Playlist,
type RichContent,
type Role,
type User,
} from "./interfaces.ts";
import {
ATTACHMENTS_DIR,
DB_PATH,
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
import { runMigrations } from "../db/migrate.ts";
export const db = new DatabaseSync(DB_PATH);
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a
// commit no longer waits on an fsync (a crash can lose the last transactions,
// never corrupt the file). busy_timeout covers the init/migration scripts and
// the test harness opening the same file.
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
`);
runMigrations(db);
/**
* Runs `fn` inside one transaction: several writes commit together (and cost
* one commit rather than one each), and a throw rolls them all back. Nested
* calls join the outer transaction.
*/
export function transaction<T>(fn: () => T): T {
if (db.isTransaction) return fn();
db.exec("BEGIN;");
try {
const result = fn();
db.exec("COMMIT;");
return result;
} catch (err) {
db.exec("ROLLBACK;");
throw err;
}
}
// Purge expired/used password reset tokens on startup
db.prepare(
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
).run();
// Purge expired unused invites on startup
db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
).run();
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
const orphanedAttachments = db.prepare(
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).all() as { id: string }[];
if (orphanedAttachments.length > 0) {
for (const { id } of orphanedAttachments) {
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
}
db.prepare(
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).run();
}
// Create default admin user if no users exist
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
count: number;
};
if (userCount.count === 0) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`;
db.prepare(
`INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`,
).run(crypto.randomUUID(), passwordHash);
console.log("Created default admin user (username: admin, password: admin)");
}
/**
* Database Row Types
*/
export interface DumpRow {
id: string;
kind: string;
title: string;
comment: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
slug: string | null;
url: string | null;
rich_content: string | null;
file_name: string | null;
file_mime: string | null;
file_size: number | null;
vote_count: number;
comment_count: number;
is_private: number;
custom_thumbnail_mime: string | null;
[key: string]: SQLOutputValue; // Index signature
}
// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow above.
// Services import these instead of repeating the column list themselves.
export const DUMP_COLUMNS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
export const DUMP_COLUMNS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime";
export const DUMP_SELECT_COLUMNS = `${DUMP_COLUMNS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
export const DUMP_SELECT_COLUMNS_ALIASED = `${DUMP_COLUMNS_ALIASED},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
export interface UserRow {
id: string;
username: string;
password_hash: string;
role: Role;
created_at: string;
updated_at: string | null;
avatar_mime: string | null;
description: string | null;
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
invited_by_username: string | null;
email: string;
[key: string]: SQLOutputValue; // Index signature
}
/**
* Type Guards
*/
export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"kind" in obj && typeof obj.kind === "string" &&
"title" in obj && typeof obj.title === "string" &&
"comment" in obj &&
(typeof obj.comment === "string" || obj.comment === null) &&
"user_id" in obj && typeof obj.user_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
"rich_content" in obj &&
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
"file_name" in obj &&
(typeof obj.file_name === "string" || obj.file_name === null) &&
"file_mime" in obj &&
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
"file_size" in obj &&
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" ||
obj.custom_thumbnail_mime === null);
}
export function isUserRow(obj: unknown): obj is UserRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"username" in obj && typeof obj.username === "string" &&
"password_hash" in obj && typeof obj.password_hash === "string" &&
"role" in obj && isRole(obj.role) &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"avatar_mime" in obj &&
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"invited_by" in obj &&
(typeof obj.invited_by === "string" || obj.invited_by === null) &&
"email" in obj && typeof obj.email === "string";
}
/**
* Conversion Helpers
*/
export function dumpRowToApi(row: DumpRow): Dump {
return {
id: row.id,
kind: row.kind as "url" | "file",
title: row.title,
slug: row.slug ?? undefined,
url: row.url ?? undefined,
comment: row.comment ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent)
: undefined,
fileName: row.file_name ?? undefined,
fileMime: row.file_mime ?? undefined,
fileSize: row.file_size ?? undefined,
voteCount: row.vote_count,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
thumbnailMime: row.custom_thumbnail_mime ?? undefined,
};
}
export function dumpApiToRow(dump: Dump): DumpRow {
return {
id: dump.id,
kind: dump.kind,
title: dump.title,
slug: dump.slug ?? null,
comment: dump.comment ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
updated_at: dump.updatedAt?.toISOString() ?? null,
url: dump.url ?? null,
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
file_name: dump.fileName ?? null,
file_mime: dump.fileMime ?? null,
file_size: dump.fileSize ?? null,
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null,
};
}
export function userRowToApi(row: UserRow): User {
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
role: row.role,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
avatarMime: row.avatar_mime ?? undefined,
description: row.description ?? undefined,
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
: undefined,
email: row.email,
};
}
export function userApiToRow(user: User): UserRow {
return {
id: user.id,
username: user.username,
password_hash: user.passwordHash,
role: user.role,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt?.toISOString() ?? null,
avatar_mime: user.avatarMime ?? null,
description: user.description ?? null,
invited_by: null,
invited_by_username: null,
email: user.email,
};
}
export interface CommentRow {
id: string;
dump_id: string;
user_id: string;
parent_id: string | null;
body: string;
created_at: string;
updated_at: string | null;
deleted: number;
like_count: number;
author_username: string;
author_avatar_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isCommentRow(
obj: unknown,
): obj is CommentRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"dump_id" in obj && typeof obj.dump_id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"parent_id" in obj &&
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"deleted" in obj && typeof obj.deleted === "number" &&
"like_count" in obj && typeof obj.like_count === "number" &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
export function commentRowToApi(row: CommentRow): Comment {
return {
id: row.id,
dumpId: row.dump_id,
userId: row.user_id,
parentId: row.parent_id ?? undefined,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted),
likeCount: row.like_count,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
};
}
export interface ChatMessageRow {
id: string;
user_id: string;
body: string;
created_at: string;
updated_at: string | null;
author_username: string;
author_avatar_mime: string | null;
// Reply columns: reply_to_id is the stored target; the author/body preview are
// LEFT-JOINed from the target and go null when there's no reply (or it was
// since deleted).
reply_to_id: string | null;
reply_to_author: string | null;
reply_to_body: string | null;
[key: string]: SQLOutputValue;
}
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null) &&
"reply_to_id" in obj &&
(typeof obj.reply_to_id === "string" || obj.reply_to_id === null) &&
"reply_to_author" in obj &&
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
"reply_to_body" in obj &&
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
}
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
return {
id: row.id,
userId: row.user_id,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
replyToId: row.reply_to_id ?? undefined,
replyToAuthor: row.reply_to_author ?? undefined,
replyToBody: row.reply_to_body ?? undefined,
};
}
export interface PlaylistRow {
id: string;
user_id: string;
title: string;
slug: string | null;
description: string | null;
is_public: number;
created_at: string;
updated_at: string | null;
image_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isPlaylistRow(
obj: unknown,
): obj is PlaylistRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"title" in obj && typeof obj.title === "string" &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"is_public" in obj && typeof obj.is_public === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"image_mime" in obj &&
(typeof obj.image_mime === "string" || obj.image_mime === null);
}
export function playlistRowToApi(row: PlaylistRow): Playlist {
return {
id: row.id,
userId: row.user_id,
title: row.title,
slug: row.slug ?? undefined,
description: row.description ?? undefined,
isPublic: Boolean(row.is_public),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string"
? row.owner_username
: undefined,
};
}
// ── Categories ────────────────────────────────────────────────────────────────
export interface CategoryRow {
id: string;
slug: string;
name: string;
position: number;
created_at: string;
updated_at: string | null;
[key: string]: SQLOutputValue;
}
export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"slug" in obj && typeof obj.slug === "string" &&
"name" in obj && typeof obj.name === "string" &&
"position" in obj && typeof obj.position === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null);
}
export function categoryRowToApi(row: CategoryRow): Category {
return {
id: row.id,
slug: row.slug,
name: row.name,
position: row.position,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
};
}
export interface FollowRow {
id: string;
follower_id: string;
followed_user_id: string | null;
followed_playlist_id: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isFollowRow(
obj: unknown,
): obj is FollowRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"follower_id" in obj && typeof obj.follower_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"followed_user_id" in obj && (obj.followed_user_id === null ||
typeof obj.followed_user_id === "string") &&
"followed_playlist_id" in obj && (obj.followed_playlist_id === null ||
typeof obj.followed_playlist_id === "string");
}
// ── Notifications ─────────────────────────────────────────────────────────────
export interface NotificationRow {
id: string;
user_id: string;
type: string;
data: string;
read: number;
created_at: string;
source_key: string | null;
[key: string]: SQLOutputValue;
}
export function isNotificationRow(
obj: unknown,
): obj is NotificationRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"type" in obj && typeof obj.type === "string" &&
"data" in obj && typeof obj.data === "string" &&
"read" in obj && typeof obj.read === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"source_key" in obj &&
(typeof obj.source_key === "string" || obj.source_key === null);
}
export function notificationRowToApi(row: NotificationRow): Notification {
return {
id: row.id,
userId: row.user_id,
type: row.type as NotificationType,
data: JSON.parse(row.data) as NotificationData,
read: Boolean(row.read),
createdAt: new Date(row.created_at),
};
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export interface PasswordResetTokenRow {
token: string;
user_id: string;
expires_at: string;
used_at: string | null;
[key: string]: SQLOutputValue;
}
export function isPasswordResetTokenRow(
obj: unknown,
): obj is PasswordResetTokenRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"expires_at" in obj && typeof obj.expires_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}
// ── Invites ───────────────────────────────────────────────────────────────────
export interface InviteRow {
token: string;
inviter_id: string;
used_at: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isInviteRow(
obj: unknown,
): obj is InviteRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}

113
api/model/dump.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { SQLOutputValue } from "node:sqlite";
import type { Dump, RichContent } from "../../shared/dump.ts";
export interface DumpRow {
id: string;
kind: string;
title: string;
comment: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
slug: string | null;
url: string | null;
rich_content: string | null;
file_name: string | null;
file_mime: string | null;
file_size: number | null;
vote_count: number;
comment_count: number;
is_private: number;
custom_thumbnail_mime: string | null;
[key: string]: SQLOutputValue; // Index signature
}
// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow.
// Services import these instead of repeating the column list themselves.
export const DUMP_COLUMNS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
export const DUMP_COLUMNS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime";
export const DUMP_SELECT_COLUMNS = `${DUMP_COLUMNS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
export const DUMP_SELECT_COLUMNS_ALIASED = `${DUMP_COLUMNS_ALIASED},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"kind" in obj && typeof obj.kind === "string" &&
"title" in obj && typeof obj.title === "string" &&
"comment" in obj &&
(typeof obj.comment === "string" || obj.comment === null) &&
"user_id" in obj && typeof obj.user_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
"rich_content" in obj &&
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
"file_name" in obj &&
(typeof obj.file_name === "string" || obj.file_name === null) &&
"file_mime" in obj &&
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
"file_size" in obj &&
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" ||
obj.custom_thumbnail_mime === null);
}
export function dumpRowToApi(row: DumpRow): Dump {
return {
id: row.id,
kind: row.kind as "url" | "file",
title: row.title,
slug: row.slug ?? undefined,
url: row.url ?? undefined,
comment: row.comment ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent)
: undefined,
fileName: row.file_name ?? undefined,
fileMime: row.file_mime ?? undefined,
fileSize: row.file_size ?? undefined,
voteCount: row.vote_count,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
thumbnailMime: row.custom_thumbnail_mime ?? undefined,
};
}
export function dumpApiToRow(dump: Dump): DumpRow {
return {
id: dump.id,
kind: dump.kind,
title: dump.title,
slug: dump.slug ?? null,
comment: dump.comment ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
updated_at: dump.updatedAt?.toISOString() ?? null,
url: dump.url ?? null,
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
file_name: dump.fileName ?? null,
file_mime: dump.fileMime ?? null,
file_size: dump.fileSize ?? null,
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null,
};
}

23
api/model/follow.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { SQLOutputValue } from "node:sqlite";
export interface FollowRow {
id: string;
follower_id: string;
followed_user_id: string | null;
followed_playlist_id: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isFollowRow(
obj: unknown,
): obj is FollowRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"follower_id" in obj && typeof obj.follower_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"followed_user_id" in obj && (obj.followed_user_id === null ||
typeof obj.followed_user_id === "string") &&
"followed_playlist_id" in obj && (obj.followed_playlist_id === null ||
typeof obj.followed_playlist_id === "string");
}

File diff suppressed because it is too large Load Diff

20
api/model/invite.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { SQLOutputValue } from "node:sqlite";
export interface InviteRow {
token: string;
inviter_id: string;
used_at: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isInviteRow(
obj: unknown,
): obj is InviteRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}

42
api/model/notification.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { SQLOutputValue } from "node:sqlite";
import type {
Notification,
NotificationData,
NotificationType,
} from "../../shared/notification.ts";
export interface NotificationRow {
id: string;
user_id: string;
type: string;
data: string;
read: number;
created_at: string;
source_key: string | null;
[key: string]: SQLOutputValue;
}
export function isNotificationRow(
obj: unknown,
): obj is NotificationRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"type" in obj && typeof obj.type === "string" &&
"data" in obj && typeof obj.data === "string" &&
"read" in obj && typeof obj.read === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"source_key" in obj &&
(typeof obj.source_key === "string" || obj.source_key === null);
}
export function notificationRowToApi(row: NotificationRow): Notification {
return {
id: row.id,
userId: row.user_id,
type: row.type as NotificationType,
data: JSON.parse(row.data) as NotificationData,
read: Boolean(row.read),
createdAt: new Date(row.created_at),
};
}

View File

@@ -0,0 +1,20 @@
import type { SQLOutputValue } from "node:sqlite";
export interface PasswordResetTokenRow {
token: string;
user_id: string;
expires_at: string;
used_at: string | null;
[key: string]: SQLOutputValue;
}
export function isPasswordResetTokenRow(
obj: unknown,
): obj is PasswordResetTokenRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"expires_at" in obj && typeof obj.expires_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}

51
api/model/playlist.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { SQLOutputValue } from "node:sqlite";
import type { Playlist } from "../../shared/playlist.ts";
export interface PlaylistRow {
id: string;
user_id: string;
title: string;
slug: string | null;
description: string | null;
is_public: number;
created_at: string;
updated_at: string | null;
image_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isPlaylistRow(
obj: unknown,
): obj is PlaylistRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"title" in obj && typeof obj.title === "string" &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"is_public" in obj && typeof obj.is_public === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"image_mime" in obj &&
(typeof obj.image_mime === "string" || obj.image_mime === null);
}
export function playlistRowToApi(row: PlaylistRow): Playlist {
return {
id: row.id,
userId: row.user_id,
title: row.title,
slug: row.slug ?? undefined,
description: row.description ?? undefined,
isPublic: Boolean(row.is_public),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string"
? row.owner_username
: undefined,
};
}

90
api/model/user.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { SQLOutputValue } from "node:sqlite";
import {
isRole,
type PublicUser,
type Role,
type SelfUser,
} from "../../shared/user.ts";
/** A user as stored: what they see of themselves, plus the password hash. */
export interface User extends SelfUser {
passwordHash: string;
}
export function toPublicUser(
{ passwordHash: _, email: _email, ...user }: User,
): PublicUser {
return user;
}
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
return user;
}
export interface UserRow {
id: string;
username: string;
password_hash: string;
role: Role;
created_at: string;
updated_at: string | null;
avatar_mime: string | null;
description: string | null;
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
invited_by_username: string | null;
email: string;
[key: string]: SQLOutputValue; // Index signature
}
export function isUserRow(obj: unknown): obj is UserRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"username" in obj && typeof obj.username === "string" &&
"password_hash" in obj && typeof obj.password_hash === "string" &&
"role" in obj && isRole(obj.role) &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"avatar_mime" in obj &&
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"invited_by" in obj &&
(typeof obj.invited_by === "string" || obj.invited_by === null) &&
"email" in obj && typeof obj.email === "string";
}
export function userRowToApi(row: UserRow): User {
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
role: row.role,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
avatarMime: row.avatar_mime ?? undefined,
description: row.description ?? undefined,
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
: undefined,
email: row.email,
};
}
export function userApiToRow(user: User): UserRow {
return {
id: user.id,
username: user.username,
password_hash: user.passwordHash,
role: user.role,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt?.toISOString() ?? null,
avatar_mime: user.avatarMime ?? null,
description: user.description ?? null,
invited_by: null,
invited_by_username: null,
email: user.email,
};
}

View File

@@ -1,14 +1,8 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import {
fetchWithTimeout,
isValidHttpUrl,
readTextCapped,
} from "./rich-content-service.ts";
import {
BANDCAMP_HOST_RE,
parseTralbum,
type Tralbum,
} from "./bandcamp-tralbum.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts";
/**
* Resolves a Bandcamp page to its streamable tracks, at play time.

View File

@@ -15,42 +15,17 @@
* which callers turn into a fallback to the iframe embed.
*/
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
* match this and keep using the embed. */
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
export interface TralbumTrack {
/** Position in the parsed array — the stable handle used to re-resolve a
* track after its signed URL expires. */
index: number;
trackNum?: number;
title: string;
/** Seconds, fractional. */
duration?: number;
streamUrl: string | null;
/** False for preorder, unreleased and still-encoding tracks. They stay in
* the list so numbering matches the release, but never enter the queue. */
streamable: boolean;
/** Bandcamp caps free plays per track; a capped track may serve a clip. */
capped: boolean;
}
export interface Tralbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: TralbumTrack[];
/** Epoch ms of the fetch that produced these signed URLs. */
resolvedAt: number;
}
/**
* Decode HTML entities in a single pass.
*
* The single pass is the point: `decodeHtmlEntities` in rich-content-service.ts
* The single pass is the point: `decodeHtmlEntities` in remote/html.ts
* replaces `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
* (`&amp;quot;`, i.e. the literal text `&quot;` inside a title) decodes twice
* and yields a bare `"` — which lands inside the JSON string and breaks the

241
api/remote/fetch.ts Normal file
View File

@@ -0,0 +1,241 @@
import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts";
import { isSelfUrl } from "../lib/self-url.ts";
const FETCH_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
};
const MAX_REDIRECTS = 5;
/**
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
* Returns null if curl is unavailable or exits non-zero.
*
* `address` pins the connection to the IP already checked by
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
* back with its Location so fetchWithTimeout can check the next hop too.
*/
async function fetchViaCurl(
url: URL,
address: string,
timeoutMs: number,
): Promise<Response | null> {
const tmpPath = await Deno.makeTempFile();
const port = url.port || (url.protocol === "https:" ? "443" : "80");
const pinned = address.includes(":") ? `[${address}]` : address;
try {
const { code, stdout } = await new Deno.Command("curl", {
args: [
"--silent",
"--insecure",
"--proto",
"=http,https",
"--resolve",
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
"--max-time",
String(Math.ceil(timeoutMs / 1000)),
"--max-filesize",
String(2 * MAX_PAGE_BYTES),
"--user-agent",
FETCH_HEADERS["User-Agent"],
"--header",
`Accept: ${FETCH_HEADERS["Accept"]}`,
"--header",
`Accept-Language: ${FETCH_HEADERS["Accept-Language"]}`,
"--output",
tmpPath,
"--write-out",
"%{http_code}\n%{redirect_url}\n%{content_type}",
url.href,
],
stdout: "piped",
stderr: "null",
}).output();
if (code !== 0) return null;
const [status, location, contentType = ""] = new TextDecoder()
.decode(stdout).split("\n").map((l) => l.trim());
const statusCode = Number(status);
if (!(statusCode >= 200 && statusCode <= 599)) return null;
const headers = new Headers();
if (location) headers.set("location", location);
if (contentType) headers.set("content-type", contentType);
const bytes = await Deno.readFile(tmpPath);
if (!contentType.startsWith("text/")) {
return new Response(bytes, { status: statusCode, headers });
}
// Decode using the charset declared in the Content-Type header so that
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
let bodyText: string;
try {
bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes);
} catch {
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
return new Response(bodyText, { status: statusCode, headers });
} catch {
return null;
} finally {
await Deno.remove(tmpPath).catch(() => {});
}
}
/**
* Most bytes of a fetched page that are read. Metadata lives in the <head>,
* and a "page" past this is a download, not something to scrape.
*/
export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
/**
* Hands `body` on, and clears the request's deadline once it has been read to
* the end or cancelled. Until then the deadline still applies: a server that
* answers at once and then drips its body can't hold the request open.
*/
function withDeadline(
body: ReadableStream<Uint8Array>,
timer: ReturnType<typeof setTimeout>,
): ReadableStream<Uint8Array> {
const reader = body.getReader();
return new ReadableStream({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
clearTimeout(timer);
controller.close();
} else {
controller.enqueue(value);
}
} catch (err) {
clearTimeout(timer);
controller.error(err);
}
},
cancel(reason) {
clearTimeout(timer);
return reader.cancel(reason);
},
});
}
/**
* Reads a response's body as text, stopping at `maxBytes` (the rest is
* cancelled, never downloaded).
*/
export async function readTextCapped(
res: Response,
maxBytes = MAX_PAGE_BYTES,
): Promise<string> {
if (!res.body) return "";
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (size < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.byteLength;
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(Math.min(size, maxBytes));
let offset = 0;
for (const chunk of chunks) {
const part = chunk.subarray(0, bytes.byteLength - offset);
bytes.set(part, offset);
offset += part.byteLength;
if (offset >= bytes.byteLength) break;
}
return new TextDecoder().decode(bytes);
}
/** One request, no redirects followed. The timeout covers the body too. */
async function fetchOnce(
url: URL,
address: string,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(url, {
signal: controller.signal,
headers: FETCH_HEADERS,
redirect: "manual",
});
} catch (err) {
clearTimeout(timer);
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
throw err;
}
// Deno/rustls rejected the server's TLS certificate (expired, self-signed,
// or an unsupported signature algorithm) and offers no way to override
// verification programmatically. Fall back to a `curl --insecure`
// subprocess, which uses its own TLS stack.
const curlRes = await fetchViaCurl(url, address, timeoutMs);
if (curlRes) return curlRes;
throw err;
}
if (!res.body) {
clearTimeout(timer);
return res;
}
return new Response(withDeadline(res.body, timer), {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
/**
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
* must resolve to a public address (see resolvePublicAddress), so a link or a
* redirect can't point the server at its own network.
*/
export async function fetchWithTimeout(
url: string,
timeoutMs = 5000,
): Promise<Response> {
let current = new URL(url);
for (let hop = 0;; hop++) {
if (current.protocol !== "http:" && current.protocol !== "https:") {
throw new TypeError(`Unsupported protocol ${current.protocol}`);
}
const address = await resolvePublicAddress(current);
const res = await fetchOnce(current, address, timeoutMs);
const location = res.headers.get("location");
if (res.status < 300 || res.status >= 400 || !location) return res;
await res.body?.cancel();
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
current = new URL(location, current);
}
}
export function isValidHttpUrl(raw: string): boolean {
try {
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
// This site's own pages are fine even on a private host (e.g. localhost in
// development): selfProvider reads them from the DB, no HTTP involved.
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
return !isPrivateHostname(u) || isSelfUrl(u);
} catch {
return false;
}
}

403
api/remote/html.ts Normal file
View File

@@ -0,0 +1,403 @@
function decodeHtmlEntities(str: string): string {
return str
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&apos;/gi, "'")
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
.replace(
/&#x([0-9a-f]+);/gi,
(_, hex) => String.fromCodePoint(parseInt(hex, 16)),
);
}
/**
* Find the first `<meta>` tag whose `attr` equals `value` and return its
* `content` (either attribute ordering). Each tag is matched in isolation so
* the `content` capture can never span across tag boundaries.
*/
function extractMetaContent(
html: string,
attr: string,
value: string,
): string | undefined {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i");
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const tagRe = /<meta\b[^>]*>/gi;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!keyRe.test(tag)) continue;
const content = contentRe.exec(tag);
if (content) return decodeHtmlEntities(content[2]);
}
return undefined;
}
export function extractOgTag(
html: string,
tag: string,
): string | undefined {
return extractMetaContent(html, "property", `og:${tag}`);
}
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
export function extractMetaName(
html: string,
name: string,
): string | undefined {
return extractMetaContent(html, "name", name);
}
/** Extract the text content of the `<title>` element. */
export function extractPageTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
}
// ── Brand color helpers ───────────────────────────────────────────────────────
/**
* Find the page's `theme-color`.
*
* Sites routinely ship the tag twice, scoped by `media`, and often list the
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
*/
export function extractThemeColor(html: string): string | undefined {
const tagRe = /<meta\b[^>]*>/gi;
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
let unscoped: string | undefined;
let light: string | undefined;
let any: string | undefined;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
const content = contentRe.exec(tag)?.[2];
if (!content) continue;
const media = mediaRe.exec(tag)?.[2];
if (!media) unscoped ??= content;
else if (/light/i.test(media)) light ??= content;
any ??= content;
}
return unscoped ?? light ?? any;
}
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
if (color) return color;
}
return undefined;
}
// The handful of named colors that actually show up in `theme-color`. The full
// 148-name table isn't worth carrying for the long tail.
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
/**
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
* only ever used as a tint over the app's own surface.
*/
export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
const named = NAMED_COLORS[value];
if (named) return named;
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
const [r, g, b] = [...hex.slice(0, 3)];
return `#${r}${r}${g}${g}${b}${b}`;
}
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
return undefined;
}
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (rgb) {
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
if (parts.length !== 3) return undefined;
const bytes = parts.map((p) => {
const n = parseFloat(p);
if (!Number.isFinite(n)) return NaN;
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
});
if (bytes.some(Number.isNaN)) return undefined;
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
return undefined;
}
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
type JsonLdResult = {
title?: string;
description?: string;
thumbnailUrl?: string;
};
function ldString(v: unknown): string | undefined {
if (typeof v === "string" && v.trim()) return v.trim();
if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) {
return v[0].trim();
}
return undefined;
}
function ldImage(v: unknown): string | undefined {
if (
typeof v === "string" &&
(v.startsWith("http://") || v.startsWith("https://"))
) return v;
if (Array.isArray(v)) return ldImage(v[0]);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return ldImage(o.url ?? o.contentUrl);
}
return undefined;
}
function ldExtractNode(data: unknown): JsonLdResult {
if (Array.isArray(data)) {
for (const item of data) {
const r = ldExtractNode(item);
if (r.title || r.thumbnailUrl) return r;
}
return {};
}
if (!data || typeof data !== "object") return {};
const o = data as Record<string, unknown>;
if (o["@graph"]) return ldExtractNode(o["@graph"]);
return {
title: ldString(o.name ?? o.headline),
description: ldString(o.description),
thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail),
};
}
/**
* Parse every `<script type="application/ld+json">` block and return the first
* node that yields a title or image. Handles `@graph`, arrays, and the common
* `image` shapes (string, string[], ImageObject).
*/
export function extractJsonLd(html: string): JsonLdResult {
const pattern =
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
try {
const result = ldExtractNode(JSON.parse(match[1]));
if (result.title || result.thumbnailUrl) return result;
} catch { /* invalid JSON — skip */ }
}
return {};
}
/**
* Return the `src` of the first `<img>` whose declared width or height is at
* least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs.
*/
export function extractLargeImage(
html: string,
baseUrl: string,
minSize = 200,
): string | undefined {
const imgPattern = /<img[^>]+>/gi;
let match: RegExpExecArray | null;
while ((match = imgPattern.exec(html)) !== null) {
const tag = match[0];
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
if (!src || src.startsWith("data:")) continue;
const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
if (w >= minSize && h >= minSize) {
try {
return new URL(src, baseUrl).toString();
} catch {
continue;
}
}
}
return undefined;
}
/**
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present.
*
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
* whatever size the placeholder renders them at.
*/
export function extractBestIcon(
html: string,
baseUrl: string,
): string | undefined {
const linkRe = /<link[^>]+>/gi;
const relRe = /\brel=["']([^"']+)["']/i;
const hrefRe = /\bhref=["']([^"']+)["']/i;
const sizesRe = /\bsizes=["']([^"']+)["']/i;
const candidates: { href: string; area: number }[] = [];
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
const rel = relRe.exec(tag)?.[1] ?? "";
if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue;
const href = hrefRe.exec(tag)?.[1];
if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
const sm = sizesStr.match(/(\d+)x(\d+)/i);
const isSvg = /\.svg(\?|$)/i.test(href) ||
/\btype=["']image\/svg\+xml["']/i.test(tag);
const area = isSvg
? Number.MAX_SAFE_INTEGER
: sm
? parseInt(sm[1]) * parseInt(sm[2])
: 0;
try {
candidates.push({ href: new URL(href, baseUrl).toString(), area });
} catch {
continue;
}
}
if (candidates.length === 0) return undefined;
candidates.sort((a, b) => b.area - a.area);
return candidates[0].href;
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename matches common
* icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.).
* Resolves relative and protocol-relative URLs.
*/
const UI_IMAGE_KEYWORDS = new Set([
"logo",
"icon",
"sprite",
"favicon",
"avatar",
"banner",
"header",
"nav",
"menu",
"cart",
"search",
"tracking",
"pixel",
"bg",
"background",
]);
function isUiImage(src: string): boolean {
if (/\.svg(\?|$)/i.test(src)) return true;
const filename = src.split("?")[0].split("/").pop() ?? "";
const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension
// Split on common filename separators (-, _, .) and check each token
return baseName.toLowerCase().split(/[-_.]/).some((t) =>
UI_IMAGE_KEYWORDS.has(t)
);
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename tokens match common
* icon/logo/nav patterns (logo, icon, sprite, etc.).
* Resolves relative and protocol-relative URLs.
*/
export function extractFirstContentImage(
html: string,
baseUrl: string,
): string | undefined {
const imgPattern = /<img[^>]+>/gi;
let match: RegExpExecArray | null;
while ((match = imgPattern.exec(html)) !== null) {
const tag = match[0];
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
if (!src || src.startsWith("data:")) continue;
if (isUiImage(src)) continue;
try {
return new URL(src, baseUrl).toString();
} catch {
continue;
}
}
return undefined;
}
/**
* Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`,
* resolved to an absolute URL using `baseUrl`.
*/
export function extractLinkHref(
html: string,
relFragment: string,
baseUrl: string,
): string | undefined {
const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(
`<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`,
"i",
),
new RegExp(
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`,
"i",
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) {
try {
return new URL(match[1], baseUrl).toString();
} catch {
return undefined;
}
}
}
return undefined;
}

106
api/remote/image-proxy.ts Normal file
View File

@@ -0,0 +1,106 @@
import { fetchWithTimeout } from "./fetch.ts";
export interface ProxiedImage {
bytes: Uint8Array;
contentType: string;
}
const PROXYABLE_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon",
]);
// Proxied images, by URL: every viewer of a feed asks for the same
// thumbnails, which were fetched from their hosts again for each one. Bounded
// by total size; the least recently used go first.
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
const imageCache = new Map<
string,
{ bytes: Uint8Array; contentType: string; at: number }
>();
let imageCacheBytes = 0;
function readImageCache(
url: string,
): { bytes: Uint8Array; contentType: string } | undefined {
const hit = imageCache.get(url);
if (!hit) return undefined;
imageCache.delete(url);
if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) {
imageCacheBytes -= hit.bytes.byteLength;
return undefined;
}
imageCache.set(url, hit); // most recently used
return hit;
}
function writeImageCache(
url: string,
bytes: Uint8Array,
contentType: string,
): void {
const old = imageCache.get(url);
if (old) {
imageCache.delete(url);
imageCacheBytes -= old.bytes.byteLength;
}
while (
imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES &&
imageCache.size > 0
) {
const [oldestUrl, oldest] = imageCache.entries().next().value!;
imageCache.delete(oldestUrl);
imageCacheBytes -= oldest.bytes.byteLength;
}
imageCache.set(url, { bytes, contentType, at: Date.now() });
imageCacheBytes += bytes.byteLength;
}
/** Forget every proxied image (tests). */
export function clearImageCache(): void {
imageCache.clear();
imageCacheBytes = 0;
}
/**
* The image at `url`, from the cache or else from its host; null when the host
* answers with something that isn't a raster image of 5 MB at most. Throws when
* the host can't be reached.
*/
export async function fetchProxiedImage(
url: string,
): Promise<ProxiedImage | null> {
const cached = readImageCache(url);
if (cached) return cached;
const res = await fetchWithTimeout(url, 8000);
// Raster formats only. The bytes go out under this site's origin, so an
// SVG (which can carry script) or anything else a browser might execute
// would run with the app's origin if opened directly.
const contentType = (res.headers.get("content-type") ?? "")
.split(";")[0].trim().toLowerCase();
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
await res.body?.cancel();
return null;
}
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > MAX_SIZE) {
await res.body?.cancel();
return null;
}
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_SIZE) return null;
writeImageCache(url, bytes, contentType);
return { bytes, contentType };
}

View File

@@ -1,10 +1,7 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import {
extractOgTag,
fetchWithTimeout,
readTextCapped,
} from "../rich-content-service.ts";
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;

View File

@@ -1,5 +1,5 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import {
extractBestIcon,
extractFirstContentImage,
@@ -10,10 +10,9 @@ import {
extractOgTag,
extractPageTitle,
extractThemeColor,
fetchWithTimeout,
normalizeCssColor,
readTextCapped,
} from "../rich-content-service.ts";
} from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
export const genericProvider: RichContentProvider = {
name: "generic",

View File

@@ -0,0 +1,8 @@
import type { RichContent } from "../../../shared/dump.ts";
/** Metadata for the links one kind of site serves. */
export interface RichContentProvider {
name: string;
matches(url: string): boolean;
fetch(url: string): Promise<RichContent>;
}

View File

@@ -1,8 +1,8 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import { getDump } from "../dump-service.ts";
import { getUserByUsername } from "../user-service.ts";
import { getPlaylistById } from "../playlist-service.ts";
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { getDump } from "../../services/dump-service.ts";
import { getUserByUsername } from "../../services/user-service.ts";
import { getPlaylistById } from "../../services/playlist-service.ts";
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
import { isSelfUrl } from "../../lib/self-url.ts";

View File

@@ -1,10 +1,7 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import {
extractOgTag,
fetchWithTimeout,
readTextCapped,
} from "../rich-content-service.ts";
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
export const soundcloudProvider: RichContentProvider = {
name: "soundcloud",

View File

@@ -1,10 +1,7 @@
import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import {
extractOgTag,
fetchWithTimeout,
readTextCapped,
} from "../rich-content-service.ts";
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
function extractVideoId(url: string): string | null {
try {

36
api/remote/twemoji.ts Normal file
View File

@@ -0,0 +1,36 @@
// Pinned Twemoji release.
const TWEMOJI_VERSION = "16.0.1";
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
const glyphCache = new Map<string, string>();
/**
* Twemoji's plain vector-path SVG for `emoji`, whose file is named `cp` (see
* emojiToCodepoint in lib/site-icons.ts).
*/
export async function fetchTwemojiSvg(
emoji: string,
cp: string,
): Promise<string> {
const cached = glyphCache.get(cp);
if (cached) return cached;
const url =
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
let res: Response;
try {
res = await fetch(url);
} catch (cause) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
{ cause },
);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`,
);
}
const svg = await res.text();
glyphCache.set(cp, svg);
return svg;
}

View File

@@ -1,5 +1,10 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { readTextCapped } from "./rich-content-service.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { readTextCapped } from "./fetch.ts";
import type {
YouTubePlaylist,
YouTubePlaylistTrack,
} from "../../shared/player.ts";
/**
* Resolves a YouTube playlist to its tracklist, at play time.
@@ -17,19 +22,6 @@ import { readTextCapped } from "./rich-content-service.ts";
* further IDs the player reports, untitled.
*/
export interface YouTubePlaylistTrack {
videoId: string;
title: string;
/** Seconds. */
duration?: number;
}
export interface YouTubePlaylist {
listId: string;
title?: string;
tracks: YouTubePlaylistTrack[];
}
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
const TTL_MS = 6 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;

View File

@@ -4,12 +4,9 @@ import {
createAttachment,
getAttachment,
} from "../services/attachment-service.ts";
import {
APIErrorCode,
APIException,
type APIResponse,
type AttachmentResponse,
} from "../model/interfaces.ts";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { AttachmentResponse } from "../../shared/dump.ts";
import {
ATTACHMENTS_DIR,
serveUploadedFile,

View File

@@ -5,14 +5,10 @@ import {
broadcastUserUpdated,
updateClientAvatar,
} from "../services/ws-service.ts";
import {
APIErrorCode,
APIException,
type APIResponse,
type SelfUser,
toPublicUser,
toSelfUser,
} from "../model/interfaces.ts";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { SelfUser } from "../../shared/user.ts";
import { toPublicUser, toSelfUser } from "../model/user.ts";
import {
AVATARS_DIR,
serveUploadedFile,

View File

@@ -1,7 +1,7 @@
import { Router } from "@oak/oak";
import type { APIResponse } from "../model/interfaces.ts";
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
import type { Tralbum } from "../services/bandcamp-tralbum.ts";
import type { APIResponse } from "../../shared/api.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
import type { Tralbum } from "../../shared/player.ts";
const bandcampRouter = new Router();

View File

@@ -2,14 +2,16 @@ import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Category,
type Dump,
type PaginatedData,
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Category } from "../../shared/category.ts";
import type { Dump } from "../../shared/dump.ts";
import {
isCreateCategoryRequest,
isUpdateCategoryRequest,
type PaginatedData,
} from "../model/interfaces.ts";
} from "../validators/category.ts";
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";

View File

@@ -1,5 +1,6 @@
import { Router } from "@oak/oak";
import type { APIResponse, ChatMessage } from "../model/interfaces.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import { authMiddleware } from "../middleware/auth.ts";
import {
getChatMessagesAfter,

View File

@@ -1,15 +1,17 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Comment,
type PaginatedData,
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts";
import {
isCreateCommentRequest,
isUpdateCommentRequest,
type PaginatedData,
type PublicUser,
toPublicUser,
} from "../model/interfaces.ts";
} from "../validators/comment.ts";
import type { PublicUser } from "../../shared/user.ts";
import { toPublicUser } from "../model/user.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import { parseOptionalAuth } from "../lib/auth.ts";

View File

@@ -2,17 +2,18 @@ import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Dump,
type DumpUrlMatch,
type PaginatedData,
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
import {
isCreateUrlDumpRequest,
isUpdateDumpRequest,
type PaginatedData,
type Playlist,
type PublicUser,
toPublicUser,
} from "../model/interfaces.ts";
} from "../validators/dump.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts";
import { toPublicUser } from "../model/user.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";

View File

@@ -1,5 +1,6 @@
import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { DUMPS_DIR } from "../lib/upload.ts";
import { authenticateMedia } from "../lib/auth.ts";
import { serveFile } from "../lib/serve-file.ts";

View File

@@ -1,11 +1,8 @@
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import {
type APIResponse,
type Dump,
type FollowStatus,
type PaginatedData,
} from "../model/interfaces.ts";
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import type { FollowStatus } from "../../shared/follow.ts";
import { type Page, parsePagination } from "../lib/pagination.ts";
import {
followPlaylist,

View File

@@ -1,10 +1,7 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type InviteResponse,
} from "../model/interfaces.ts";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { InviteResponse } from "../../shared/user.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { createInvite, validateInvite } from "../services/invite-service.ts";

View File

@@ -1,11 +1,11 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Notification,
type PaginatedData,
} from "../model/interfaces.ts";
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Notification } from "../../shared/notification.ts";
import { parsePagination } from "../lib/pagination.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {

View File

@@ -1,16 +1,17 @@
import { Router } from "@oak/oak";
import { parseOptionalAuth } from "../lib/auth.ts";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
APIErrorCode,
APIException,
type APIResponse,
isCreatePlaylistRequest,
isReorderPlaylistRequest,
isUpdatePlaylistRequest,
type Playlist,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../model/interfaces.ts";
} from "../validators/playlist.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../../shared/playlist.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import {

View File

@@ -1,30 +1,17 @@
import { Router } from "@oak/oak";
import {
fetchWithTimeout,
isValidHttpUrl,
tryFetchRichContent,
} from "../services/rich-content-service.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts";
import { tryFetchRichContent } from "../services/rich-content-service.ts";
import {
APIErrorCode,
type APIFailure,
type APIResponse,
type LinkPreview,
} from "../model/interfaces.ts";
} from "../../shared/api.ts";
import type { LinkPreview } from "../../shared/dump.ts";
import { authMiddleware } from "../middleware/auth.ts";
const previewRouter = new Router();
const PROXYABLE_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon",
]);
// Signed-in only: it's the create form's preview, and an open one lets anyone
// use the server to fetch pages for them.
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
@@ -50,59 +37,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// Proxied images, by URL: every viewer of a feed asks for the same
// thumbnails, which were fetched from their hosts again for each one. Bounded
// by total size; the least recently used go first.
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
const imageCache = new Map<
string,
{ bytes: Uint8Array; contentType: string; at: number }
>();
let imageCacheBytes = 0;
function readImageCache(
url: string,
): { bytes: Uint8Array; contentType: string } | undefined {
const hit = imageCache.get(url);
if (!hit) return undefined;
imageCache.delete(url);
if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) {
imageCacheBytes -= hit.bytes.byteLength;
return undefined;
}
imageCache.set(url, hit); // most recently used
return hit;
}
function writeImageCache(
url: string,
bytes: Uint8Array,
contentType: string,
): void {
const old = imageCache.get(url);
if (old) {
imageCache.delete(url);
imageCacheBytes -= old.bytes.byteLength;
}
while (
imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES &&
imageCache.size > 0
) {
const [oldestUrl, oldest] = imageCache.entries().next().value!;
imageCache.delete(oldestUrl);
imageCacheBytes -= oldest.bytes.byteLength;
}
imageCache.set(url, { bytes, contentType, at: Date.now() });
imageCacheBytes += bytes.byteLength;
}
/** Forget every proxied image (tests). */
export function clearImageCache(): void {
imageCache.clear();
imageCacheBytes = 0;
}
function sendImage(
ctx: { response: { headers: Headers; body?: unknown } },
bytes: Uint8Array,
@@ -129,41 +63,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
return;
}
const cached = readImageCache(url);
if (cached) {
sendImage(ctx, cached.bytes, cached.contentType);
return;
}
try {
const res = await fetchWithTimeout(url, 8000);
// Raster formats only. The bytes go out under this site's origin, so an
// SVG (which can carry script) or anything else a browser might execute
// would run with the app's origin if opened directly.
const contentType = (res.headers.get("content-type") ?? "")
.split(";")[0].trim().toLowerCase();
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
await res.body?.cancel();
const image = await fetchProxiedImage(url);
if (!image) {
ctx.response.status = 400;
return;
}
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > MAX_SIZE) {
await res.body?.cancel();
ctx.response.status = 400;
return;
}
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_SIZE) {
ctx.response.status = 400;
return;
}
writeImageCache(url, bytes, contentType);
sendImage(ctx, bytes, contentType);
sendImage(ctx, image.bytes, image.contentType);
} catch {
ctx.response.status = 502;
}

View File

@@ -4,7 +4,8 @@ import { parsePagination } from "../lib/pagination.ts";
import { searchDumps } from "../services/dump-service.ts";
import { searchUsersGlobal } from "../services/user-service.ts";
import { searchPlaylists } from "../services/playlist-service.ts";
import type { APIResponse, SearchResults } from "../model/interfaces.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts";
const router = new Router({ prefix: "/api/search" });

View File

@@ -1,5 +1,6 @@
import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
dumpFileVersion,
getDump,

View File

@@ -2,28 +2,30 @@ import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type PaginatedData,
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
type AuthResponse,
type AuthTokens,
type Dump,
fieldOf,
type Invitee,
isLoginUserRequest,
isRegisterUserRequest,
isRole,
isUpdateUserRequest,
type MediaTokenResponse,
type PaginatedData,
type Playlist,
type PublicUser,
type SelfUser,
toPublicUser,
toSelfUser,
type User,
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import {
fieldOf,
isLoginUserRequest,
isRegisterUserRequest,
isUpdateUserRequest,
validateRegisterUserRequest,
} from "../model/interfaces.ts";
} from "../validators/user.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { toPublicUser, toSelfUser, type User } from "../model/user.ts";
import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
@@ -48,12 +50,8 @@ import {
resetPassword,
} from "../services/password-reset-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import {
FROM_EMAIL,
OG_SITE_NAME,
VALIDATION,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {

View File

@@ -27,8 +27,11 @@ import {
markChatRead,
updateChatMessage,
} from "../services/chat-service.ts";
import { notifyMentions } from "../services/notification-service.ts";
import { hasPermission } from "../lib/permissions.ts";
import {
getUnreadCount,
notifyMentions,
} from "../services/notification-service.ts";
import { hasPermission } from "../../shared/permissions.ts";
import {
castVote,
getUserVotes,
@@ -39,14 +42,13 @@ import {
getUserCommentLikes,
removeCommentLike,
} from "../services/comment-like-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import {
APIException,
type ClientToServerMessage,
type ServerToClientMessage,
type User,
} from "../model/interfaces.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
ClientToServerMessage,
ServerToClientMessage,
} from "../../shared/ws.ts";
import type { User } from "../model/user.ts";
const router = new Router();

View File

@@ -1,9 +1,7 @@
import { Router } from "@oak/oak";
import type { APIResponse } from "../model/interfaces.ts";
import {
resolveYouTubePlaylist,
type YouTubePlaylist,
} from "../services/youtube-playlist-service.ts";
import type { APIResponse } from "../../shared/api.ts";
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
import type { YouTubePlaylist } from "../../shared/player.ts";
const youtubeRouter = new Router();

View File

@@ -1,5 +1,6 @@
import { db, transaction } from "../model/db.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, transaction } from "../db/connection.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
export function createAttachment(id: string, mime: string): void {
db.prepare(

View File

@@ -1,11 +1,10 @@
import { type Dump } from "../model/interfaces.ts";
import type { Dump } from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
db,
DUMP_SELECT_COLUMNS as SELECT_COLS,
dumpRowToApi,
isDumpRow,
transaction,
} from "../model/db.ts";
} from "../model/dump.ts";
import { UUID_RE } from "../lib/slugify.ts";
// Matches http(s) URLs in free text — stops at whitespace or markdown-link

View File

@@ -1,23 +1,21 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
Category,
CreateCategoryRequest,
UpdateCategoryRequest,
} from "../../shared/category.ts";
import type { Dump } from "../../shared/dump.ts";
import { categoryRowToApi, isCategoryRow } from "../model/category.ts";
import { db, transaction } from "../db/connection.ts";
import {
APIErrorCode,
APIException,
type Category,
type CreateCategoryRequest,
type Dump,
type UpdateCategoryRequest,
} from "../model/interfaces.ts";
import {
categoryRowToApi,
db,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpRowToApi,
isCategoryRow,
isDumpRow,
transaction,
} from "../model/db.ts";
} from "../model/dump.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../config.ts";
import { VALIDATION } from "../../shared/validation.ts";
function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) {

View File

@@ -1,12 +1,11 @@
import {
APIErrorCode,
APIException,
type ChatMessage,
} from "../model/interfaces.ts";
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
@@ -51,11 +50,11 @@ export function createChatMessage(
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
// Only store the reply target if it actually exists — drop dangling ids so a
@@ -111,11 +110,11 @@ export function updateChatMessage(
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)

View File

@@ -1,5 +1,7 @@
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
import { db } from "../db/connection.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts";

View File

@@ -1,10 +1,9 @@
import {
APIErrorCode,
APIException,
type Comment,
} from "../model/interfaces.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts";
import { type SQLOutputValue } from "node:sqlite";
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
import { commentRowToApi, isCommentRow } from "../model/comment.ts";
import { db } from "../db/connection.ts";
import {
notifyDumpOwnerNewComment,
notifyMentions,

View File

@@ -1,26 +1,25 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
CreateUrlDumpRequest,
Dump,
DumpUrlMatch,
UpdateDumpRequest,
} from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
APIErrorCode,
APIException,
type CreateUrlDumpRequest,
type Dump,
type DumpUrlMatch,
type UpdateDumpRequest,
} from "../model/interfaces.ts";
import {
db,
DUMP_SELECT_COLUMNS as SELECT_COLS,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpApiToRow,
dumpRowToApi,
isDumpRow,
transaction,
} from "../model/db.ts";
} from "../model/dump.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import {
fetchRichContent,
isValidHttpUrl,
tryFetchRichContent,
} from "./rich-content-service.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import {
broadcastDumpDeleted,
broadcastDumpUpdated,
@@ -35,11 +34,13 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR,
THUMBNAILS_DIR,
VALIDATION,
} from "../config.ts";
import {
DUMP_MAX_FILE_SIZE_BYTES,
VALIDATION,
} from "../../shared/validation.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import {
@@ -240,7 +241,7 @@ export async function createFileDump(
/**
* 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.
* `dumpFileVersion` in src/utils/shared/urls.ts.
*/
export function dumpFileVersion(
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,

View File

@@ -1,26 +1,21 @@
import {
APIErrorCode,
APIException,
type Dump,
type FollowStatus,
type Playlist,
type User,
} from "../model/interfaces.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Dump } from "../../shared/dump.ts";
import type { FollowStatus } from "../../shared/follow.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
import {
notifyPlaylistOwnerNewFollower,
notifyUserNewFollower,
} from "./notification-service.ts";
import { db } from "../db/connection.ts";
import {
db,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpRowToApi,
isDumpRow,
isFollowRow,
isPlaylistRow,
isUserRow,
playlistRowToApi,
userRowToApi,
} from "../model/db.ts";
} from "../model/dump.ts";
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds } from "./category-service.ts";

View File

@@ -1,6 +1,8 @@
import { randomBytes } from "node:crypto";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isInviteRow } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { db } from "../db/connection.ts";
import { isInviteRow } from "../model/invite.ts";
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */

View File

@@ -3,20 +3,17 @@ import type {
NotificationData,
NotificationType,
UserDumpPostedData,
} from "../model/interfaces.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
} from "../../shared/notification.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import {
db,
isNotificationRow,
notificationRowToApi,
transaction,
} from "../model/db.ts";
} from "../model/notification.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.ts";
// Regex: matches @username not already inside a markdown link ([...] or (...)
// and not mid-word, so an email address doesn't mention its domain.
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
import { MENTION_RE } from "../../shared/mention.ts";
// ── Core CRUD ─────────────────────────────────────────────────────────────────
@@ -28,7 +25,8 @@ function createNotification(
sourceKey: string | null = null,
): void {
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
const now = new Date();
const createdAt = now.toISOString();
const dataJson = JSON.stringify(data);
let changes: number;
@@ -51,7 +49,14 @@ function createNotification(
if (changes > 0) {
sendToUser(userId, {
type: "notification_created",
notification: { id, userId, type, data, read: false, createdAt },
notification: {
id,
userId,
type,
data,
read: false,
createdAt: now,
},
});
}
}
@@ -166,7 +171,8 @@ export function notifyUserFollowersNewDump(
dumpTitle,
};
const dataJson = JSON.stringify(data);
const createdAt = new Date().toISOString();
const now = new Date();
const createdAt = now.toISOString();
const sourceKey = `dump:${dumpId}`;
// Insert per follower so we can broadcast only the rows that were actually
@@ -201,7 +207,7 @@ export function notifyUserFollowersNewDump(
type: "user_dump_posted",
data,
read: false,
createdAt,
createdAt: now,
},
});
}

View File

@@ -1,17 +1,15 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isPasswordResetTokenRow } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { db } from "../db/connection.ts";
import { isPasswordResetTokenRow } from "../model/password-reset.ts";
import {
createPasswordResetToken,
verifyPasswordResetToken,
} from "../lib/jwt.ts";
import { getUserByEmail, updateUser } from "./user-service.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import {
FROM_EMAIL,
FRONTEND_URL,
OG_SITE_NAME,
VALIDATION,
} from "../config.ts";
import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME } from "../config.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { marked } from "marked";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";

View File

@@ -1,22 +1,20 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
CreatePlaylistRequest,
Playlist,
PlaylistMembership,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../shared/playlist.ts";
import type { Dump } from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
APIErrorCode,
APIException,
type CreatePlaylistRequest,
type Dump,
type Playlist,
type PlaylistMembership,
type PlaylistWithDumps,
type UpdatePlaylistRequest,
} from "../model/interfaces.ts";
import {
db,
DUMP_COLUMNS_ALIASED,
dumpRowToApi,
isDumpRow,
isPlaylistRow,
playlistRowToApi,
transaction,
} from "../model/db.ts";
} from "../model/dump.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import {
broadcastPlaylistCreated,

View File

@@ -1,20 +1,10 @@
import type { RichContent } from "../model/interfaces.ts";
import { youtubeProvider } from "./providers/youtube.ts";
import { bandcampProvider } from "./providers/bandcamp.ts";
import { soundcloudProvider } from "./providers/soundcloud.ts";
import { selfProvider } from "./providers/self.ts";
import { genericProvider } from "./providers/generic.ts";
import {
isPrivateHostname,
resolvePublicAddress,
} from "../lib/public-address.ts";
import { isSelfUrl } from "../lib/self-url.ts";
export interface RichContentProvider {
name: string;
matches(url: string): boolean;
fetch(url: string): Promise<RichContent>;
}
import type { RichContent } from "../../shared/dump.ts";
import type { RichContentProvider } from "../remote/providers/provider.ts";
import { youtubeProvider } from "../remote/providers/youtube.ts";
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
import { soundcloudProvider } from "../remote/providers/soundcloud.ts";
import { selfProvider } from "../remote/providers/self.ts";
import { genericProvider } from "../remote/providers/generic.ts";
/**
* Register providers in priority order. The first match wins.
@@ -29,651 +19,6 @@ const providers: RichContentProvider[] = [
genericProvider,
];
// Shared utilities exported for use by providers
const FETCH_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
};
const MAX_REDIRECTS = 5;
/**
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
* Returns null if curl is unavailable or exits non-zero.
*
* `address` pins the connection to the IP already checked by
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
* back with its Location so fetchWithTimeout can check the next hop too.
*/
async function fetchViaCurl(
url: URL,
address: string,
timeoutMs: number,
): Promise<Response | null> {
const tmpPath = await Deno.makeTempFile();
const port = url.port || (url.protocol === "https:" ? "443" : "80");
const pinned = address.includes(":") ? `[${address}]` : address;
try {
const { code, stdout } = await new Deno.Command("curl", {
args: [
"--silent",
"--insecure",
"--proto",
"=http,https",
"--resolve",
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
"--max-time",
String(Math.ceil(timeoutMs / 1000)),
"--max-filesize",
String(2 * MAX_PAGE_BYTES),
"--user-agent",
FETCH_HEADERS["User-Agent"],
"--header",
`Accept: ${FETCH_HEADERS["Accept"]}`,
"--header",
`Accept-Language: ${FETCH_HEADERS["Accept-Language"]}`,
"--output",
tmpPath,
"--write-out",
"%{http_code}\n%{redirect_url}\n%{content_type}",
url.href,
],
stdout: "piped",
stderr: "null",
}).output();
if (code !== 0) return null;
const [status, location, contentType = ""] = new TextDecoder()
.decode(stdout).split("\n").map((l) => l.trim());
const statusCode = Number(status);
if (!(statusCode >= 200 && statusCode <= 599)) return null;
const headers = new Headers();
if (location) headers.set("location", location);
if (contentType) headers.set("content-type", contentType);
const bytes = await Deno.readFile(tmpPath);
if (!contentType.startsWith("text/")) {
return new Response(bytes, { status: statusCode, headers });
}
// Decode using the charset declared in the Content-Type header so that
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
let bodyText: string;
try {
bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes);
} catch {
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
return new Response(bodyText, { status: statusCode, headers });
} catch {
return null;
} finally {
await Deno.remove(tmpPath).catch(() => {});
}
}
/**
* Most bytes of a fetched page that are read. Metadata lives in the <head>,
* and a "page" past this is a download, not something to scrape.
*/
export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
/**
* Hands `body` on, and clears the request's deadline once it has been read to
* the end or cancelled. Until then the deadline still applies: a server that
* answers at once and then drips its body can't hold the request open.
*/
function withDeadline(
body: ReadableStream<Uint8Array>,
timer: ReturnType<typeof setTimeout>,
): ReadableStream<Uint8Array> {
const reader = body.getReader();
return new ReadableStream({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
clearTimeout(timer);
controller.close();
} else {
controller.enqueue(value);
}
} catch (err) {
clearTimeout(timer);
controller.error(err);
}
},
cancel(reason) {
clearTimeout(timer);
return reader.cancel(reason);
},
});
}
/**
* Reads a response's body as text, stopping at `maxBytes` (the rest is
* cancelled, never downloaded).
*/
export async function readTextCapped(
res: Response,
maxBytes = MAX_PAGE_BYTES,
): Promise<string> {
if (!res.body) return "";
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (size < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.byteLength;
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(Math.min(size, maxBytes));
let offset = 0;
for (const chunk of chunks) {
const part = chunk.subarray(0, bytes.byteLength - offset);
bytes.set(part, offset);
offset += part.byteLength;
if (offset >= bytes.byteLength) break;
}
return new TextDecoder().decode(bytes);
}
/** One request, no redirects followed. The timeout covers the body too. */
async function fetchOnce(
url: URL,
address: string,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(url, {
signal: controller.signal,
headers: FETCH_HEADERS,
redirect: "manual",
});
} catch (err) {
clearTimeout(timer);
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
throw err;
}
// Deno/rustls rejected the server's TLS certificate (expired, self-signed,
// or an unsupported signature algorithm) and offers no way to override
// verification programmatically. Fall back to a `curl --insecure`
// subprocess, which uses its own TLS stack.
const curlRes = await fetchViaCurl(url, address, timeoutMs);
if (curlRes) return curlRes;
throw err;
}
if (!res.body) {
clearTimeout(timer);
return res;
}
return new Response(withDeadline(res.body, timer), {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
/**
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
* must resolve to a public address (see resolvePublicAddress), so a link or a
* redirect can't point the server at its own network.
*/
export async function fetchWithTimeout(
url: string,
timeoutMs = 5000,
): Promise<Response> {
let current = new URL(url);
for (let hop = 0;; hop++) {
if (current.protocol !== "http:" && current.protocol !== "https:") {
throw new TypeError(`Unsupported protocol ${current.protocol}`);
}
const address = await resolvePublicAddress(current);
const res = await fetchOnce(current, address, timeoutMs);
const location = res.headers.get("location");
if (res.status < 300 || res.status >= 400 || !location) return res;
await res.body?.cancel();
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
current = new URL(location, current);
}
}
function decodeHtmlEntities(str: string): string {
return str
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&apos;/gi, "'")
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
.replace(
/&#x([0-9a-f]+);/gi,
(_, hex) => String.fromCodePoint(parseInt(hex, 16)),
);
}
/**
* Find the first `<meta>` tag whose `attr` equals `value` and return its
* `content` (either attribute ordering). Each tag is matched in isolation so
* the `content` capture can never span across tag boundaries.
*/
function extractMetaContent(
html: string,
attr: string,
value: string,
): string | undefined {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i");
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const tagRe = /<meta\b[^>]*>/gi;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!keyRe.test(tag)) continue;
const content = contentRe.exec(tag);
if (content) return decodeHtmlEntities(content[2]);
}
return undefined;
}
export function extractOgTag(
html: string,
tag: string,
): string | undefined {
return extractMetaContent(html, "property", `og:${tag}`);
}
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
export function extractMetaName(
html: string,
name: string,
): string | undefined {
return extractMetaContent(html, "name", name);
}
/** Extract the text content of the `<title>` element. */
export function extractPageTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
}
// ── Brand color helpers ───────────────────────────────────────────────────────
/**
* Find the page's `theme-color`.
*
* Sites routinely ship the tag twice, scoped by `media`, and often list the
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
*/
export function extractThemeColor(html: string): string | undefined {
const tagRe = /<meta\b[^>]*>/gi;
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
let unscoped: string | undefined;
let light: string | undefined;
let any: string | undefined;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
const content = contentRe.exec(tag)?.[2];
if (!content) continue;
const media = mediaRe.exec(tag)?.[2];
if (!media) unscoped ??= content;
else if (/light/i.test(media)) light ??= content;
any ??= content;
}
return unscoped ?? light ?? any;
}
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
if (color) return color;
}
return undefined;
}
// The handful of named colors that actually show up in `theme-color`. The full
// 148-name table isn't worth carrying for the long tail.
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
/**
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
* only ever used as a tint over the app's own surface.
*/
export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
const named = NAMED_COLORS[value];
if (named) return named;
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
const [r, g, b] = [...hex.slice(0, 3)];
return `#${r}${r}${g}${g}${b}${b}`;
}
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
return undefined;
}
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (rgb) {
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
if (parts.length !== 3) return undefined;
const bytes = parts.map((p) => {
const n = parseFloat(p);
if (!Number.isFinite(n)) return NaN;
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
});
if (bytes.some(Number.isNaN)) return undefined;
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
return undefined;
}
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
type JsonLdResult = {
title?: string;
description?: string;
thumbnailUrl?: string;
};
function ldString(v: unknown): string | undefined {
if (typeof v === "string" && v.trim()) return v.trim();
if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) {
return v[0].trim();
}
return undefined;
}
function ldImage(v: unknown): string | undefined {
if (
typeof v === "string" &&
(v.startsWith("http://") || v.startsWith("https://"))
) return v;
if (Array.isArray(v)) return ldImage(v[0]);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return ldImage(o.url ?? o.contentUrl);
}
return undefined;
}
function ldExtractNode(data: unknown): JsonLdResult {
if (Array.isArray(data)) {
for (const item of data) {
const r = ldExtractNode(item);
if (r.title || r.thumbnailUrl) return r;
}
return {};
}
if (!data || typeof data !== "object") return {};
const o = data as Record<string, unknown>;
if (o["@graph"]) return ldExtractNode(o["@graph"]);
return {
title: ldString(o.name ?? o.headline),
description: ldString(o.description),
thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail),
};
}
/**
* Parse every `<script type="application/ld+json">` block and return the first
* node that yields a title or image. Handles `@graph`, arrays, and the common
* `image` shapes (string, string[], ImageObject).
*/
export function extractJsonLd(html: string): JsonLdResult {
const pattern =
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
try {
const result = ldExtractNode(JSON.parse(match[1]));
if (result.title || result.thumbnailUrl) return result;
} catch { /* invalid JSON — skip */ }
}
return {};
}
/**
* Return the `src` of the first `<img>` whose declared width or height is at
* least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs.
*/
export function extractLargeImage(
html: string,
baseUrl: string,
minSize = 200,
): string | undefined {
const imgPattern = /<img[^>]+>/gi;
let match: RegExpExecArray | null;
while ((match = imgPattern.exec(html)) !== null) {
const tag = match[0];
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
if (!src || src.startsWith("data:")) continue;
const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
if (w >= minSize && h >= minSize) {
try {
return new URL(src, baseUrl).toString();
} catch {
continue;
}
}
}
return undefined;
}
/**
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present.
*
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
* whatever size the placeholder renders them at.
*/
export function extractBestIcon(
html: string,
baseUrl: string,
): string | undefined {
const linkRe = /<link[^>]+>/gi;
const relRe = /\brel=["']([^"']+)["']/i;
const hrefRe = /\bhref=["']([^"']+)["']/i;
const sizesRe = /\bsizes=["']([^"']+)["']/i;
const candidates: { href: string; area: number }[] = [];
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
const rel = relRe.exec(tag)?.[1] ?? "";
if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue;
const href = hrefRe.exec(tag)?.[1];
if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
const sm = sizesStr.match(/(\d+)x(\d+)/i);
const isSvg = /\.svg(\?|$)/i.test(href) ||
/\btype=["']image\/svg\+xml["']/i.test(tag);
const area = isSvg
? Number.MAX_SAFE_INTEGER
: sm
? parseInt(sm[1]) * parseInt(sm[2])
: 0;
try {
candidates.push({ href: new URL(href, baseUrl).toString(), area });
} catch {
continue;
}
}
if (candidates.length === 0) return undefined;
candidates.sort((a, b) => b.area - a.area);
return candidates[0].href;
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename matches common
* icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.).
* Resolves relative and protocol-relative URLs.
*/
const UI_IMAGE_KEYWORDS = new Set([
"logo",
"icon",
"sprite",
"favicon",
"avatar",
"banner",
"header",
"nav",
"menu",
"cart",
"search",
"tracking",
"pixel",
"bg",
"background",
]);
function isUiImage(src: string): boolean {
if (/\.svg(\?|$)/i.test(src)) return true;
const filename = src.split("?")[0].split("/").pop() ?? "";
const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension
// Split on common filename separators (-, _, .) and check each token
return baseName.toLowerCase().split(/[-_.]/).some((t) =>
UI_IMAGE_KEYWORDS.has(t)
);
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename tokens match common
* icon/logo/nav patterns (logo, icon, sprite, etc.).
* Resolves relative and protocol-relative URLs.
*/
export function extractFirstContentImage(
html: string,
baseUrl: string,
): string | undefined {
const imgPattern = /<img[^>]+>/gi;
let match: RegExpExecArray | null;
while ((match = imgPattern.exec(html)) !== null) {
const tag = match[0];
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
if (!src || src.startsWith("data:")) continue;
if (isUiImage(src)) continue;
try {
return new URL(src, baseUrl).toString();
} catch {
continue;
}
}
return undefined;
}
/**
* Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`,
* resolved to an absolute URL using `baseUrl`.
*/
export function extractLinkHref(
html: string,
relFragment: string,
baseUrl: string,
): string | undefined {
const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(
`<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`,
"i",
),
new RegExp(
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`,
"i",
),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) {
try {
return new URL(match[1], baseUrl).toString();
} catch {
return undefined;
}
}
}
return undefined;
}
export function isValidHttpUrl(raw: string): boolean {
try {
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
// This site's own pages are fine even on a private host (e.g. localhost in
// development): selfProvider reads them from the DB, no HTTP involved.
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
return !isPrivateHostname(u) || isSelfUrl(u);
} catch {
return false;
}
}
export interface FetchRichContentResult {
/** False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. Callers that already hold good metadata must

View File

@@ -1,15 +1,21 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
Invitee,
PublicUser,
RegisterUserRequest,
Role,
UpdateUserRequest,
UserSuggestion,
} from "../../shared/user.ts";
import {
APIErrorCode,
APIException,
type Invitee,
type PublicUser,
type RegisterUserRequest,
isUserRow,
toPublicUser,
type UpdateUserRequest,
type User,
type UserSuggestion,
} from "../model/interfaces.ts";
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
userApiToRow,
userRowToApi,
} from "../model/user.ts";
import { db } from "../db/connection.ts";
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
import { hashPassword } from "../lib/jwt.ts";
@@ -92,6 +98,25 @@ export function getUserById(userId: string): User {
return userRowToApi(userRow);
}
/**
* What a session token is checked against: the user's live role, and the
* moment older tokens stopped counting (their last password change). Undefined
* when the user is gone.
*/
export function getSessionState(
userId: string,
): { role: Role; tokensValidAfter: string | null } | undefined {
const row = db.prepare(
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
).get(userId) as
| { role: Role; tokens_valid_after: string | null }
| undefined;
return row && {
role: row.role,
tokensValidAfter: row.tokens_valid_after,
};
}
export function getUserByUsername(username: string): User {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.username = ?`,

View File

@@ -1,5 +1,7 @@
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
import { db } from "../db/connection.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts";

View File

@@ -1,13 +1,9 @@
import type {
ChatMessage,
Comment,
Dump,
OnlineUser,
Playlist,
PublicUser,
Role,
ServerToClientMessage,
} from "../model/interfaces.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import type { Comment } from "../../shared/comment.ts";
import type { Dump } from "../../shared/dump.ts";
import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts";
import {
isMemberVisibleTo,
type PlaylistMember,

View File

@@ -0,0 +1,41 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { runMigrations } from "../../db/migrate.ts";
const SCHEMA = Deno.readTextFileSync(
new URL("../../db/schema.sql", import.meta.url),
);
function freshDb(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(SCHEMA);
return db;
}
function schemaOf(db: DatabaseSync): string[] {
return (db.prepare(
`SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`,
).all() as { sql: string }[]).map((r) => r.sql);
}
Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => {
const db = freshDb();
const before = schemaOf(db);
runMigrations(db);
assertEquals(schemaOf(db), before);
const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`)
.get() as {
n: number;
};
assertEquals(applied.n, 16);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
16,
);
db.close();
});

View File

@@ -0,0 +1,28 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0004 } from "../../../db/migrations/0004_user_roles.ts";
import { up as up0005 } from "../../../db/migrations/0005_drop_is_admin.ts";
Deno.test("0004/0005: is_admin becomes role, then goes", () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`,
);
db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`);
up0004(db);
up0004(db);
up0005(db);
up0005(db);
assertEquals(
db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({
...r,
})),
[{ id: "a", role: "admin" }, { id: "b", role: "user" }],
);
const cols =
(db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[])
.map((c) => c.name);
assertEquals(cols, ["id", "role"]);
db.close();
});

View File

@@ -0,0 +1,67 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0010 } from "../../../db/migrations/0010_youtube_embed_start.ts";
const SCHEMA = Deno.readTextFileSync(
new URL("../../../db/schema.sql", import.meta.url),
);
function freshDb(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(SCHEMA);
return db;
}
function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) {
db.prepare(
`INSERT INTO users (id, username, password_hash, created_at, email)
VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`,
).run();
db.prepare(
`INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content)
VALUES (?, 'url', 't', 'u', 'now', ?, ?);`,
).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich));
}
function richOf(db: DatabaseSync, id: string): Record<string, unknown> {
return JSON.parse(
(db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as {
rich_content: string;
}).rich_content,
);
}
Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => {
const db = freshDb();
const embed = "https://www.youtube.com/embed/abc?rel=0";
insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "plain", "https://youtu.be/abc?start=42", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "none", "https://youtu.be/abc", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "has", "https://youtu.be/abc?t=5", {
type: "youtube",
embedUrl: `${embed}&start=9`,
});
insertRich(db, "other", "https://example.com/?t=5", {
type: "generic",
embedUrl: embed,
});
insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json");
up0010(db);
up0010(db);
assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`);
assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`);
assertEquals(richOf(db, "none").embedUrl, embed);
assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`);
assertEquals(richOf(db, "other").embedUrl, embed);
db.close();
});

View File

@@ -0,0 +1,91 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0011 } from "../../../db/migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012 } from "../../../db/migrations/0012_fix_favicon_reclassification.ts";
const SCHEMA = Deno.readTextFileSync(
new URL("../../../db/schema.sql", import.meta.url),
);
function freshDb(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(SCHEMA);
return db;
}
function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) {
db.prepare(
`INSERT INTO users (id, username, password_hash, created_at, email)
VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`,
).run();
db.prepare(
`INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content)
VALUES (?, 'url', 't', 'u', 'now', ?, ?);`,
).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich));
}
function richOf(db: DatabaseSync, id: string): Record<string, unknown> {
return JSON.parse(
(db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as {
rich_content: string;
}).rich_content,
);
}
Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => {
const db = freshDb();
const cases: Record<
string,
[string, string | undefined, string | undefined]
> = {
// id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after]
ico: [
"https://a.test/favicon.ico",
undefined,
"https://a.test/favicon.ico",
],
touch: [
"https://a.test/apple-touch-icon.png",
undefined,
"https://a.test/apple-touch-icon.png",
],
art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined],
// 0011 mistook these for icons; 0012 puts them back.
logoFavicon: [
"https://a.test/uploads/mir-logo-favicon.png",
"https://a.test/uploads/mir-logo-favicon.png",
undefined,
],
svgArt: [
"https://a.test/art/poster.svg",
"https://a.test/art/poster.svg",
undefined,
],
// 0011 missed this one; 0012 catches it.
iconName: [
"https://a.test/img/icon_SEARCH.png",
undefined,
"https://a.test/img/icon_SEARCH.png",
],
};
for (const [id, [thumbnailUrl]] of Object.entries(cases)) {
insertRich(db, id, "https://a.test/", {
type: "generic",
url: "u",
thumbnailUrl,
});
}
up0011(db);
up0011(db);
up0012(db);
up0012(db);
for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) {
const rich = richOf(db, id);
assertEquals([rich.thumbnailUrl, rich.faviconUrl], [
thumbnailUrl,
faviconUrl,
], id);
}
db.close();
});

View File

@@ -0,0 +1,31 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { canonicalizeUrl } from "../../../lib/canonical-url.ts";
import { up as up0013 } from "../../../db/migrations/0013_dump_url_canonical.ts";
import { DatabaseSync } from "node:sqlite";
// Migration 0013 carries a frozen copy of the canonicalizer to backfill old
// rows. Until the rules change on purpose (with a new backfill migration), the
// two must agree, or rows from before and after the migration won't match.
Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => {
const urls = [
"http://www.example.com/a/?utm_source=x&b=2&a=1#top",
"https://youtu.be/abc123?t=5",
"https://www.youtube.com/playlist?list=PLx",
"https://app.example.com/#/route",
];
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`);
urls.forEach((url, i) =>
db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url)
);
up0013(db);
up0013(db); // idempotent
const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`)
.all() as { id: string; url_canonical: string }[];
assertEquals(
rows.map((r) => r.url_canonical),
urls.map(canonicalizeUrl),
);
db.close();
});

View File

@@ -3,13 +3,14 @@ import { TEST_DIR } from "./setup.ts";
import { stub } from "@std/testing/mock";
import { app } from "../main.ts";
import { db } from "../model/db.ts";
import { db } from "../db/connection.ts";
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
import { makeSlug } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../routes/preview.ts";
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
import { clearImageCache } from "../remote/image-proxy.ts";
import type { APIError, APIResponse } from "../../shared/api.ts";
import type { Role } from "../../shared/user.ts";
export { app, db, TEST_DIR };

View File

@@ -1,162 +0,0 @@
// Small pure helpers: slugs, pagination, permissions, rate limiting, image
// sniffing, self-links and the favicon codepoint.
import "./setup.ts";
import {
assert,
assertEquals,
assertFalse,
assertMatch,
assertThrows,
} from "@std/assert";
import { FakeTime } from "@std/testing/time";
import { makeSlug, slugify, UUID_RE } from "../lib/slugify.ts";
import {
isReservedSlug,
isSlugAvailable,
isValidSlugFormat,
} from "../lib/reserved-slugs.ts";
import { parsePagination } from "../lib/pagination.ts";
import { can, hasPermission, ROLE_PERMISSIONS } from "../lib/permissions.ts";
import { takeToken } from "../lib/rate-limit.ts";
import { detectImageMime, validateImageUpload } from "../lib/upload.ts";
import { isSelfUrl } from "../lib/self-url.ts";
import { emojiToCodepoint } from "../lib/site-icons.ts";
import { APIException } from "../model/interfaces.ts";
import { PNG } from "./helpers.ts";
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => {
assertEquals(slugify("Hello, World!"), "hello-world");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
assertEquals(slugify("日本語"), "untitled");
assertEquals(slugify(""), "untitled");
assertEquals(slugify("x".repeat(100)).length, 60);
});
Deno.test("makeSlug: suffixes the first 8 characters of the id", () => {
const id = "0123abcd-0000-4000-8000-000000000000";
assertEquals(makeSlug("My Song", id), "my-song-0123abcd");
assert(UUID_RE.test(id));
assertFalse(UUID_RE.test("my-song-0123abcd"));
});
Deno.test("category slugs: format and reserved words", () => {
assert(isValidSlugFormat("music"));
assert(isValidSlugFormat("lo-fi-2"));
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
assertFalse(isValidSlugFormat(bad), bad);
}
assert(isReservedSlug("dumps"));
assert(isReservedSlug("api"));
assertFalse(isReservedSlug("music"));
assert(isSlugAvailable("music"));
assertFalse(isSlugAvailable("users"));
assertFalse(isSlugAvailable("Nope"));
});
Deno.test("parsePagination: defaults, bounds and garbage", () => {
const p = (q: string, def?: number) =>
parsePagination(new URLSearchParams(q), def);
assertEquals(p(""), { page: 1, limit: 20 });
assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 });
assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 });
assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 });
assertEquals(p("limit=1000"), { page: 1, limit: 100 });
assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 });
assertEquals(p("", 7), { page: 1, limit: 7 });
});
Deno.test("permissions: admin has all, moderator moderates, user nothing", () => {
for (const perm of ROLE_PERMISSIONS.admin) {
assert(hasPermission("admin", perm));
}
assert(hasPermission("moderator", "dump:moderate"));
assert(hasPermission("moderator", "chat:moderate"));
assertFalse(hasPermission("moderator", "user:manage"));
assertFalse(hasPermission("moderator", "category:manage"));
assertEquals(ROLE_PERMISSIONS.user, []);
assert(
can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"),
);
assertFalse(
can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"),
);
});
Deno.test("takeToken: bursts to capacity, then refills over time", () => {
using time = new FakeTime();
const key = `test:${crypto.randomUUID()}`;
const limit = { capacity: 3, refill: 1 }; // one token a second
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
time.tick(500);
assertFalse(takeToken(key, limit));
time.tick(500);
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Refill never exceeds capacity.
time.tick(60_000);
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Keys are independent.
assert(takeToken(`${key}:other`, limit));
});
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
assertEquals(detectImageMime(PNG), "image/png");
assertEquals(
detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])),
"image/jpeg",
);
assertEquals(
detectImageMime(new TextEncoder().encode("GIF89a")),
"image/gif",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
"image/webp",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")),
null,
);
assertEquals(
detectImageMime(new TextEncoder().encode("<svg xmlns=...>")),
null,
);
assertEquals(detectImageMime(new Uint8Array()), null);
});
Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => {
assertEquals(validateImageUpload(PNG), "image/png");
assertThrows(
() => validateImageUpload(new TextEncoder().encode("<html>")),
APIException,
"not a recognised image",
);
const big = new Uint8Array(5 * 1024 * 1024 + 1);
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});
Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => {
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/search")));
assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob")));
});
Deno.test("emojiToCodepoint: Twemoji file naming", () => {
assertEquals(emojiToCodepoint("🚚"), "1f69a");
// A lone variation selector is dropped…
assertEquals(emojiToCodepoint("❤️"), "2764");
// …but kept inside a ZWJ sequence.
assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/);
});

62
api/test/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createUser, db, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { authenticate, authenticateMedia } from "../../lib/auth.ts";
test("authenticate: the role comes from the database, not the token", async () => {
const user = await createUser({ role: "user" });
db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id);
assertEquals((await authenticate(user.token))?.role, "admin");
});
test("authenticate: a deleted user's token is refused", async () => {
const user = await createUser();
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
assertEquals(await authenticate(user.token), null);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
});
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
const user = await createUser();
const future = new Date(Date.now() + 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
future,
user.id,
);
assertEquals(await authenticate(user.token), null);
assertEquals(await authenticateMedia(user.mediaToken), null);
// A token issued at the cutoff second or later passes.
const past = new Date(Date.now() - 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
past,
user.id,
);
assertEquals((await authenticate(user.token))?.userId, user.id);
});
test("authenticateMedia: takes a media token or a legacy session token", async () => {
const user = await createUser();
assertEquals(await authenticateMedia(user.mediaToken), user.id);
assertEquals(await authenticateMedia(user.token), user.id);
assertEquals(await authenticateMedia("garbage"), null);
});
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
const owner = await createUser();
// A public listing that reads optional auth.
const path = `/api/users/${owner.username}/dumps`;
assertEquals((await get(path)).status, 200);
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
assertEquals(
(await get(path, { headers: { Authorization: placeholder } })).status,
200,
placeholder,
);
}
assertEquals(
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
200,
);
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
assertEquals(bad.status, 401);
assertEquals(bad.error.code, "UNAUTHORIZED");
});

View File

@@ -1,8 +1,6 @@
import "./setup.ts";
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { up as up0013 } from "../db/migrations/0013_dump_url_canonical.ts";
import { DatabaseSync } from "node:sqlite";
import { canonicalizeUrl } from "../../lib/canonical-url.ts";
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
const want = "https://example.com/a";
@@ -76,29 +74,3 @@ Deno.test("canonicalizeUrl: non-http and unparseable input", () => {
assertEquals(canonicalizeUrl("not a url"), null);
assertEquals(canonicalizeUrl(""), null);
});
// Migration 0013 carries a frozen copy of the canonicalizer to backfill old
// rows. Until the rules change on purpose (with a new backfill migration), the
// two must agree, or rows from before and after the migration won't match.
Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => {
const urls = [
"http://www.example.com/a/?utm_source=x&b=2&a=1#top",
"https://youtu.be/abc123?t=5",
"https://www.youtube.com/playlist?list=PLx",
"https://app.example.com/#/route",
];
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`);
urls.forEach((url, i) =>
db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url)
);
up0013(db);
up0013(db); // idempotent
const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`)
.all() as { id: string; url_canonical: string }[];
assertEquals(
rows.map((r) => r.url_canonical),
urls.map(canonicalizeUrl),
);
db.close();
});

80
api/test/lib/jwt.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import "../setup.ts";
import {
assert,
assertEquals,
assertFalse,
assertNotEquals,
} from "@std/assert";
import { SignJWT } from "@panva/jose";
import {
createJWT,
createMediaToken,
createPasswordResetToken,
hashPassword,
verifyJWT,
verifyMediaToken,
verifyPassword,
verifyPasswordResetToken,
} from "../../lib/jwt.ts";
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => {
const a = await hashPassword("hunter22");
const b = await hashPassword("hunter22");
assertNotEquals(a, b);
assert(await verifyPassword("hunter22", a));
assertFalse(await verifyPassword("hunter23", a));
// A malformed stored hash never verifies.
assertFalse(await verifyPassword("hunter22", "not-a-hash"));
assertFalse(await verifyPassword("hunter22", ""));
});
Deno.test("session, media and reset tokens don't stand in for each other", async () => {
const userId = crypto.randomUUID();
const session = await createJWT({ userId, username: "u", role: "user" });
const media = await createMediaToken(userId);
const reset = await createPasswordResetToken(userId);
assertEquals((await verifyJWT(session))?.userId, userId);
assertEquals(await verifyJWT(media), null);
assertEquals(await verifyJWT(reset), null);
assertEquals((await verifyMediaToken(media))?.userId, userId);
assertEquals(await verifyMediaToken(session), null);
assertEquals(await verifyMediaToken(reset), null);
assertEquals((await verifyPasswordResetToken(reset))?.userId, userId);
assertEquals(await verifyPasswordResetToken(session), null);
assertEquals(await verifyPasswordResetToken(media), null);
});
Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => {
const forged = await new SignJWT({
userId: "x",
username: "x",
role: "admin",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(new TextEncoder().encode("some-other-secret"));
assertEquals(await verifyJWT(forged), null);
const expired = await new SignJWT({
userId: "x",
username: "x",
role: "user",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
.sign(KEY);
assertEquals(await verifyJWT(expired), null);
const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(KEY);
assertEquals(await verifyJWT(badRole), null);
assertEquals(await verifyJWT("not.a.jwt"), null);
});

View File

@@ -0,0 +1,15 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { parsePagination } from "../../lib/pagination.ts";
Deno.test("parsePagination: defaults, bounds and garbage", () => {
const p = (q: string, def?: number) =>
parsePagination(new URLSearchParams(q), def);
assertEquals(p(""), { page: 1, limit: 20 });
assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 });
assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 });
assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 });
assertEquals(p("limit=1000"), { page: 1, limit: 100 });
assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 });
assertEquals(p("", 7), { page: 1, limit: 7 });
});

View File

@@ -0,0 +1,12 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { can } from "../../lib/permissions.ts";
Deno.test("can: checks the token's role", () => {
assert(
can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"),
);
assertFalse(
can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"),
);
});

View File

@@ -0,0 +1,27 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { FakeTime } from "@std/testing/time";
import { takeToken } from "../../lib/rate-limit.ts";
Deno.test("takeToken: bursts to capacity, then refills over time", () => {
using time = new FakeTime();
const key = `test:${crypto.randomUUID()}`;
const limit = { capacity: 3, refill: 1 }; // one token a second
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
time.tick(500);
assertFalse(takeToken(key, limit));
time.tick(500);
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Refill never exceeds capacity.
time.tick(60_000);
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Keys are independent.
assert(takeToken(`${key}:other`, limit));
});

View File

@@ -0,0 +1,21 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isReservedSlug,
isSlugAvailable,
isValidSlugFormat,
} from "../../lib/reserved-slugs.ts";
Deno.test("category slugs: format and reserved words", () => {
assert(isValidSlugFormat("music"));
assert(isValidSlugFormat("lo-fi-2"));
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
assertFalse(isValidSlugFormat(bad), bad);
}
assert(isReservedSlug("dumps"));
assert(isReservedSlug("api"));
assertFalse(isReservedSlug("music"));
assert(isSlugAvailable("music"));
assertFalse(isSlugAvailable("users"));
assertFalse(isSlugAvailable("Nope"));
});

View File

@@ -0,0 +1,13 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { isSelfUrl } from "../../lib/self-url.ts";
Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => {
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/search")));
assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob")));
});

View File

@@ -0,0 +1,11 @@
import "../setup.ts";
import { assertEquals, assertMatch } from "@std/assert";
import { emojiToCodepoint } from "../../lib/site-icons.ts";
Deno.test("emojiToCodepoint: Twemoji file naming", () => {
assertEquals(emojiToCodepoint("🚚"), "1f69a");
// A lone variation selector is dropped…
assertEquals(emojiToCodepoint("❤️"), "2764");
// …but kept inside a ZWJ sequence.
assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/);
});

View File

@@ -0,0 +1,19 @@
import "../setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts";
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => {
assertEquals(slugify("Hello, World!"), "hello-world");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
assertEquals(slugify("日本語"), "untitled");
assertEquals(slugify(""), "untitled");
assertEquals(slugify("x".repeat(100)).length, 60);
});
Deno.test("makeSlug: suffixes the first 8 characters of the id", () => {
const id = "0123abcd-0000-4000-8000-000000000000";
assertEquals(makeSlug("My Song", id), "my-song-0123abcd");
assert(UUID_RE.test(id));
assertFalse(UUID_RE.test("my-song-0123abcd"));
});

View File

@@ -0,0 +1,74 @@
import { BASE_URL, TEST_DIR } from "../helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { routeStaticFilesFrom } from "../../lib/static.ts";
async function handle(
app: Application,
path: string,
headers: HeadersInit = {},
) {
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
if (!res) throw new Error("no response");
return { res, text: await res.text() };
}
Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => {
const root = `${TEST_DIR}/static-${crypto.randomUUID()}`;
await Deno.mkdir(`${root}/assets`, { recursive: true });
await Deno.writeTextFile(
`${root}/index.html`,
`<html><head><title>__SITE_NAME__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
);
const js = "console.log('app');".repeat(50);
await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js);
const br = new Uint8Array([1, 2, 3]);
await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br);
await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *");
const app = new Application();
app.use(routeStaticFilesFrom([root]));
const index = await handle(app, "/");
assertEquals(index.res.headers.get("cache-control"), "no-cache");
assertEquals(
index.text,
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
);
const spa = await handle(app, "/users/someone");
assertEquals(spa.res.status, 200);
assertEquals(spa.text, index.text);
// No Accept-Encoding header: the plain file, not the .br sibling.
const asset = await handle(app, "/assets/app-abc123.js");
assertEquals(asset.text, js);
assertEquals(asset.res.headers.get("content-encoding"), null);
assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable");
assertStringIncludes(
asset.res.headers.get("cache-control")!,
"max-age=31536000",
);
assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding");
const compressed = await handle(app, "/assets/app-abc123.js", {
"Accept-Encoding": "br, gzip",
});
assertEquals(compressed.res.headers.get("content-encoding"), "br");
assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes
const plain = await handle(app, "/robots.txt");
assertEquals(plain.text, "User-agent: *");
assertFalse(
(plain.res.headers.get("cache-control") ?? "").includes("immutable"),
);
// A chunk from an older build 404s rather than being answered with the index.
const stale = await handle(app, "/assets/app-old999.js");
assertEquals(stale.res.status, 404);
assertFalse(stale.text.includes("<html>"));
assertEquals(
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
404,
);
});

View File

@@ -0,0 +1,42 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
assertEquals(detectImageMime(PNG), "image/png");
assertEquals(
detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])),
"image/jpeg",
);
assertEquals(
detectImageMime(new TextEncoder().encode("GIF89a")),
"image/gif",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
"image/webp",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")),
null,
);
assertEquals(
detectImageMime(new TextEncoder().encode("<svg xmlns=...>")),
null,
);
assertEquals(detectImageMime(new Uint8Array()), null);
});
Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => {
assertEquals(validateImageUpload(PNG), "image/png");
assertThrows(
() => validateImageUpload(new TextEncoder().encode("<html>")),
APIException,
"not a recognised image",
);
const big = new Uint8Array(5 * 1024 * 1024 + 1);
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});

33
api/test/main.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { BASE_URL, get, request, test } from "./helpers.ts";
import { assertEquals } from "@std/assert";
test("CORS: allowed origins get the headers, others don't", async () => {
const allowed = await get("/api/categories", {
headers: { Origin: "http://allowed.test" },
});
assertEquals(
allowed.headers.get("access-control-allow-origin"),
"http://allowed.test",
);
// The public URL is always allowed.
const self = await get("/api/categories", { headers: { Origin: BASE_URL } });
assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL);
const evil = await get("/api/categories", {
headers: { Origin: "http://evil.test" },
});
assertEquals(evil.status, 200);
assertEquals(evil.headers.get("access-control-allow-origin"), null);
const preflight = await request("OPTIONS", "/api/dumps", {
headers: {
Origin: "http://allowed.test",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
},
});
assertEquals(preflight.status, 204);
assertEquals(
preflight.headers.get("access-control-allow-origin"),
"http://allowed.test",
);
});

View File

@@ -1,255 +0,0 @@
// The app-wide middleware: error rendering, CORS, OG tags for crawlers, and
// static file serving with its cache rules.
import {
BASE_URL,
createUser,
get,
insertDump,
insertPlaylist,
request,
test,
TEST_DIR,
} from "./helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { errorMiddleware } from "../middleware/error.ts";
import { ogMiddleware } from "../middleware/og.ts";
import { routeStaticFilesFrom } from "../lib/static.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
async function handle(
app: Application,
path: string,
headers: HeadersInit = {},
) {
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
if (!res) throw new Error("no response");
return { res, text: await res.text() };
}
Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => {
const app = new Application();
app.use(errorMiddleware);
app.use((ctx) => {
if (ctx.request.url.pathname === "/api-error") {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope");
}
throw new Error("secret internals");
});
const known = await handle(app, "/api-error");
assertEquals(known.res.status, 404);
assertEquals(JSON.parse(known.text), {
success: false,
error: { code: "NOT_FOUND", message: "Nope" },
});
const unknown = await handle(app, "/boom");
assertEquals(unknown.res.status, 500);
assertEquals(JSON.parse(unknown.text).error, {
code: "SERVER_ERROR",
message: "Unexpected server error",
});
});
test("CORS: allowed origins get the headers, others don't", async () => {
const allowed = await get("/api/categories", {
headers: { Origin: "http://allowed.test" },
});
assertEquals(
allowed.headers.get("access-control-allow-origin"),
"http://allowed.test",
);
// The public URL is always allowed.
const self = await get("/api/categories", { headers: { Origin: BASE_URL } });
assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL);
const evil = await get("/api/categories", {
headers: { Origin: "http://evil.test" },
});
assertEquals(evil.status, 200);
assertEquals(evil.headers.get("access-control-allow-origin"), null);
const preflight = await request("OPTIONS", "/api/dumps", {
headers: {
Origin: "http://allowed.test",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
},
});
assertEquals(preflight.status, 204);
assertEquals(
preflight.headers.get("access-control-allow-origin"),
"http://allowed.test",
);
});
test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => {
assertEquals(
(await get("/ws", { headers: { Origin: "http://evil.test" } })).status,
403,
);
assertEquals((await get("/ws")).status, 403);
assertEquals(
(await get("/ws", { headers: { Origin: "http://allowed.test" } })).status,
426,
);
});
function ogApp() {
const app = new Application();
app.use(ogMiddleware);
app.use((ctx) => {
ctx.response.body = "fallthrough";
});
return app;
}
test("ogMiddleware: dump pages get escaped OG tags", async () => {
const user = await createUser();
const dump = insertDump({
userId: user.id,
title: `Tom & "Jerry" <script>`,
comment: "A comment",
richContent: {
type: "generic",
url: "x",
thumbnailUrl: "https://img.test/a.jpg",
},
});
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
assertStringIncludes(
text,
`<meta property="og:title" content="Tom &amp; &quot;Jerry&quot; &lt;script&gt;" />`,
);
assertStringIncludes(
text,
`<title>Tom &amp; &quot;Jerry&quot; &lt;script&gt;</title>`,
);
assertStringIncludes(
text,
`<meta property="og:description" content="A comment" />`,
);
assertStringIncludes(
text,
`<meta property="og:image" content="https://img.test/a.jpg" />`,
);
assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes(
text,
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
);
// The index's own title is replaced, not duplicated.
assertEquals(text.match(/<title>/g)?.length, 1);
assertFalse(text.includes("__SITE_NAME__"));
});
test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => {
const user = await createUser();
const dump = insertDump({ userId: user.id, title: "Plain" });
const { text } = await handle(ogApp(), `/dumps/${dump.id}`);
assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`);
assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`);
const file = insertDump({
userId: user.id,
fileName: "a.png",
fileMime: "image/png",
});
assertStringIncludes(
(await handle(ogApp(), `/dumps/${file.id}`)).text,
`content="${BASE_URL}/api/files/${file.id}"`,
);
});
test("ogMiddleware: users and public playlists; private things fall through", async () => {
const user = await createUser();
const app = ogApp();
assertStringIncludes(
(await handle(app, `/users/${user.username}/~/votes`)).text,
`<meta property="og:title" content="${user.username}" />`,
);
const pub = insertPlaylist({ userId: user.id, title: "Open list" });
assertStringIncludes(
(await handle(app, `/playlists/${pub.slug}`)).text,
"Open list",
);
const priv = insertPlaylist({
userId: user.id,
title: "Closed",
isPublic: false,
});
assertEquals(
(await handle(app, `/playlists/${priv.slug}`)).text,
"fallthrough",
);
const secret = insertDump({
userId: user.id,
title: "Secret",
isPrivate: true,
});
assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough");
assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough");
assertEquals(
(await handle(app, `/api/dumps/${secret.id}`)).text,
"fallthrough",
);
assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough");
});
Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => {
const root = `${TEST_DIR}/static-${crypto.randomUUID()}`;
await Deno.mkdir(`${root}/assets`, { recursive: true });
await Deno.writeTextFile(
`${root}/index.html`,
`<html><head><title>__SITE_NAME__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
);
const js = "console.log('app');".repeat(50);
await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js);
const br = new Uint8Array([1, 2, 3]);
await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br);
await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *");
const app = new Application();
app.use(routeStaticFilesFrom([root]));
const index = await handle(app, "/");
assertEquals(index.res.headers.get("cache-control"), "no-cache");
assertEquals(
index.text,
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
);
const spa = await handle(app, "/users/someone");
assertEquals(spa.res.status, 200);
assertEquals(spa.text, index.text);
// No Accept-Encoding header: the plain file, not the .br sibling.
const asset = await handle(app, "/assets/app-abc123.js");
assertEquals(asset.text, js);
assertEquals(asset.res.headers.get("content-encoding"), null);
assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable");
assertStringIncludes(
asset.res.headers.get("cache-control")!,
"max-age=31536000",
);
assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding");
const compressed = await handle(app, "/assets/app-abc123.js", {
"Accept-Encoding": "br, gzip",
});
assertEquals(compressed.res.headers.get("content-encoding"), "br");
assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes
const plain = await handle(app, "/robots.txt");
assertEquals(plain.text, "User-agent: *");
assertFalse(
(plain.res.headers.get("cache-control") ?? "").includes("immutable"),
);
// A chunk from an older build 404s rather than being answered with the index.
const stale = await handle(app, "/assets/app-old999.js");
assertEquals(stale.res.status, 404);
assertFalse(stale.text.includes("<html>"));
assertEquals(
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
404,
);
});

View File

@@ -0,0 +1,18 @@
import { createUser, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
test("required auth: missing, malformed and forged tokens are 401", async () => {
assertEquals((await get("/api/users/me")).status, 401);
assertEquals(
(await get("/api/users/me", { headers: { Authorization: "Token x" } }))
.status,
401,
);
const media = (await createUser()).mediaToken;
assertEquals(
(await get("/api/users/me", {
headers: { Authorization: `Bearer ${media}` },
})).status,
401,
);
});

View File

@@ -0,0 +1,39 @@
import { BASE_URL } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { Application } from "@oak/oak";
import { errorMiddleware } from "../../middleware/error.ts";
import { APIErrorCode } from "../../../shared/api.ts";
import { APIException } from "../../lib/api-exception.ts";
async function handle(
app: Application,
path: string,
headers: HeadersInit = {},
) {
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
if (!res) throw new Error("no response");
return { res, text: await res.text() };
}
Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => {
const app = new Application();
app.use(errorMiddleware);
app.use((ctx) => {
if (ctx.request.url.pathname === "/api-error") {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope");
}
throw new Error("secret internals");
});
const known = await handle(app, "/api-error");
assertEquals(known.res.status, 404);
assertEquals(JSON.parse(known.text), {
success: false,
error: { code: "NOT_FOUND", message: "Nope" },
});
const unknown = await handle(app, "/boom");
assertEquals(unknown.res.status, 500);
assertEquals(JSON.parse(unknown.text).error, {
code: "SERVER_ERROR",
message: "Unexpected server error",
});
});

View File

@@ -0,0 +1,121 @@
import {
BASE_URL,
createUser,
insertDump,
insertPlaylist,
test,
} from "../helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { ogMiddleware } from "../../middleware/og.ts";
async function handle(
app: Application,
path: string,
headers: HeadersInit = {},
) {
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
if (!res) throw new Error("no response");
return { res, text: await res.text() };
}
function ogApp() {
const app = new Application();
app.use(ogMiddleware);
app.use((ctx) => {
ctx.response.body = "fallthrough";
});
return app;
}
test("ogMiddleware: dump pages get escaped OG tags", async () => {
const user = await createUser();
const dump = insertDump({
userId: user.id,
title: `Tom & "Jerry" <script>`,
comment: "A comment",
richContent: {
type: "generic",
url: "x",
thumbnailUrl: "https://img.test/a.jpg",
},
});
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
assertStringIncludes(
text,
`<meta property="og:title" content="Tom &amp; &quot;Jerry&quot; &lt;script&gt;" />`,
);
assertStringIncludes(
text,
`<title>Tom &amp; &quot;Jerry&quot; &lt;script&gt;</title>`,
);
assertStringIncludes(
text,
`<meta property="og:description" content="A comment" />`,
);
assertStringIncludes(
text,
`<meta property="og:image" content="https://img.test/a.jpg" />`,
);
assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes(
text,
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
);
// The index's own title is replaced, not duplicated.
assertEquals(text.match(/<title>/g)?.length, 1);
assertFalse(text.includes("__SITE_NAME__"));
});
test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => {
const user = await createUser();
const dump = insertDump({ userId: user.id, title: "Plain" });
const { text } = await handle(ogApp(), `/dumps/${dump.id}`);
assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`);
assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`);
const file = insertDump({
userId: user.id,
fileName: "a.png",
fileMime: "image/png",
});
assertStringIncludes(
(await handle(ogApp(), `/dumps/${file.id}`)).text,
`content="${BASE_URL}/api/files/${file.id}"`,
);
});
test("ogMiddleware: users and public playlists; private things fall through", async () => {
const user = await createUser();
const app = ogApp();
assertStringIncludes(
(await handle(app, `/users/${user.username}/~/votes`)).text,
`<meta property="og:title" content="${user.username}" />`,
);
const pub = insertPlaylist({ userId: user.id, title: "Open list" });
assertStringIncludes(
(await handle(app, `/playlists/${pub.slug}`)).text,
"Open list",
);
const priv = insertPlaylist({
userId: user.id,
title: "Closed",
isPublic: false,
});
assertEquals(
(await handle(app, `/playlists/${priv.slug}`)).text,
"fallthrough",
);
const secret = insertDump({
userId: user.id,
title: "Secret",
isPrivate: true,
});
assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough");
assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough");
assertEquals(
(await handle(app, `/api/dumps/${secret.id}`)).text,
"fallthrough",
);
assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough");
});

View File

@@ -1,182 +0,0 @@
// Migrations run against fresh databases (built from schema.sql) and old ones;
// each must be idempotent, and the data rewrites must touch only what they
// mean to.
import "./setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { runMigrations } from "../db/migrate.ts";
import { up as up0004 } from "../db/migrations/0004_user_roles.ts";
import { up as up0005 } from "../db/migrations/0005_drop_is_admin.ts";
import { up as up0010 } from "../db/migrations/0010_youtube_embed_start.ts";
import { up as up0011 } from "../db/migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012 } from "../db/migrations/0012_fix_favicon_reclassification.ts";
const SCHEMA = Deno.readTextFileSync(
new URL("../db/schema.sql", import.meta.url),
);
function freshDb(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(SCHEMA);
return db;
}
function schemaOf(db: DatabaseSync): string[] {
return (db.prepare(
`SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`,
).all() as { sql: string }[]).map((r) => r.sql);
}
Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => {
const db = freshDb();
const before = schemaOf(db);
runMigrations(db);
assertEquals(schemaOf(db), before);
const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`)
.get() as {
n: number;
};
assertEquals(applied.n, 16);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
16,
);
db.close();
});
Deno.test("0004/0005: is_admin becomes role, then goes", () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`,
);
db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`);
up0004(db);
up0004(db);
up0005(db);
up0005(db);
assertEquals(
db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({
...r,
})),
[{ id: "a", role: "admin" }, { id: "b", role: "user" }],
);
const cols =
(db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[])
.map((c) => c.name);
assertEquals(cols, ["id", "role"]);
db.close();
});
function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) {
db.prepare(
`INSERT INTO users (id, username, password_hash, created_at, email)
VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`,
).run();
db.prepare(
`INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content)
VALUES (?, 'url', 't', 'u', 'now', ?, ?);`,
).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich));
}
function richOf(db: DatabaseSync, id: string): Record<string, unknown> {
return JSON.parse(
(db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as {
rich_content: string;
}).rich_content,
);
}
Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => {
const db = freshDb();
const embed = "https://www.youtube.com/embed/abc?rel=0";
insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "plain", "https://youtu.be/abc?start=42", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "none", "https://youtu.be/abc", {
type: "youtube",
embedUrl: embed,
});
insertRich(db, "has", "https://youtu.be/abc?t=5", {
type: "youtube",
embedUrl: `${embed}&start=9`,
});
insertRich(db, "other", "https://example.com/?t=5", {
type: "generic",
embedUrl: embed,
});
insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json");
up0010(db);
up0010(db);
assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`);
assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`);
assertEquals(richOf(db, "none").embedUrl, embed);
assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`);
assertEquals(richOf(db, "other").embedUrl, embed);
db.close();
});
Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => {
const db = freshDb();
const cases: Record<
string,
[string, string | undefined, string | undefined]
> = {
// id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after]
ico: [
"https://a.test/favicon.ico",
undefined,
"https://a.test/favicon.ico",
],
touch: [
"https://a.test/apple-touch-icon.png",
undefined,
"https://a.test/apple-touch-icon.png",
],
art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined],
// 0011 mistook these for icons; 0012 puts them back.
logoFavicon: [
"https://a.test/uploads/mir-logo-favicon.png",
"https://a.test/uploads/mir-logo-favicon.png",
undefined,
],
svgArt: [
"https://a.test/art/poster.svg",
"https://a.test/art/poster.svg",
undefined,
],
// 0011 missed this one; 0012 catches it.
iconName: [
"https://a.test/img/icon_SEARCH.png",
undefined,
"https://a.test/img/icon_SEARCH.png",
],
};
for (const [id, [thumbnailUrl]] of Object.entries(cases)) {
insertRich(db, id, "https://a.test/", {
type: "generic",
url: "u",
thumbnailUrl,
});
}
up0011(db);
up0011(db);
up0012(db);
up0012(db);
for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) {
const rich = richOf(db, id);
assertEquals([rich.thumbnailUrl, rich.faviconUrl], [
thumbnailUrl,
faviconUrl,
], id);
}
db.close();
});

Some files were not shown because too many files have changed in this diff Show More