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