Compare commits
4 Commits
de5abeceb2
...
d392be7cb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d392be7cb8 | ||
|
|
e9f6a1f23e | ||
|
|
2b6e68391b | ||
|
|
07d0344be7 |
@@ -10,6 +10,7 @@ RUN deno install
|
|||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
COPY public/ ./public/
|
COPY public/ ./public/
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
# VITE_API_* are only needed when the API lives on a different host than the frontend.
|
# VITE_API_* are only needed when the API lives on a different host than the frontend.
|
||||||
ARG VITE_API_PROTOCOL
|
ARG VITE_API_PROTOCOL
|
||||||
@@ -32,6 +33,7 @@ COPY deno.json deno.lock ./
|
|||||||
RUN deno install
|
RUN deno install
|
||||||
|
|
||||||
COPY api/ ./api/
|
COPY api/ ./api/
|
||||||
|
COPY shared/ ./shared/
|
||||||
COPY --from=builder /app/dist/ ./dist/
|
COPY --from=builder /app/dist/ ./dist/
|
||||||
|
|
||||||
# Persistent data: database and uploaded/generated files must be mounted as volumes.
|
# 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
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
shared/ # Imported by both halves: API and socket types, limits, permissions
|
||||||
|
test/ # Tests of shared/ (Deno): test/<module>.test.ts
|
||||||
api/
|
api/
|
||||||
main.ts # Entry point — Oak application, middleware, routes
|
main.ts # Entry point — Oak application, middleware, routes
|
||||||
config.ts # Environment variables
|
config.ts # Environment variables
|
||||||
middleware/ # errorMiddleware, authMiddleware
|
middleware/ # Oak middleware: errors, auth, OG tags
|
||||||
routes/ # HTTP routes + WebSocket
|
routes/ # One router per URL prefix: HTTP + WebSocket, no SQL
|
||||||
services/ # Business logic
|
services/ # Business logic and every SQL query
|
||||||
|
remote/ # Everything that fetches from other sites
|
||||||
providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
|
providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
|
||||||
model/ # Row types, type guards
|
model/ # Per table: row type, row guard, row ↔ API mappers
|
||||||
lib/ # JWT, pagination, slugify, upload, static helpers, …
|
validators/ # Per domain: request body guards
|
||||||
|
lib/ # Helpers with no SQL and no outbound fetch: JWT, slugs, files, …
|
||||||
db/
|
db/
|
||||||
|
connection.ts # The database handle and transaction()
|
||||||
|
startup.ts # Housekeeping run when the database opens
|
||||||
schema.sql # Database schema
|
schema.sql # Database schema
|
||||||
init.ts # First-run database initialisation
|
init.ts # First-run database initialisation
|
||||||
migrate.ts # Migration runner
|
migrate.ts # Migration runner
|
||||||
migrations/ # Versioned schema migrations
|
migrations/ # Versioned schema migrations
|
||||||
|
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
|
||||||
sql/
|
sql/
|
||||||
gerbeur.db # SQLite database (not committed)
|
gerbeur.db # SQLite database (not committed)
|
||||||
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
|
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
|
||||||
src/ # React frontend (Vite)
|
src/ # React frontend (Vite)
|
||||||
config/ # API base URL, validation constants, feed tabs, upload limits
|
config/ # API base URL, feed tabs, player mode
|
||||||
pages/ # Route-level components
|
pages/ # One component per route, nothing else
|
||||||
index/ # Feed views (hot, new, followed, journal)
|
components/ # React components only (.tsx)
|
||||||
components/ # Shared UI components
|
contexts/ # FooContext.ts + FooProvider.tsx pairs
|
||||||
form/ # Reusable form controls
|
hooks/ # Every hook (useFoo.ts)
|
||||||
contexts/ # Auth, WebSocket, player, follows, theme
|
utils/ # Every non-React module: pure helpers, storage, API glue
|
||||||
hooks/ # Data fetching and UI hooks
|
test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
|
||||||
utils/ # Formatting, URL, hot-score, waveform, … helpers
|
|
||||||
locales/ # Lingui message catalogues (en, fr)
|
locales/ # Lingui message catalogues (en, fr)
|
||||||
themes/ # Per-theme CSS files
|
themes/ # Per-theme CSS files
|
||||||
model.ts # Shared frontend types
|
|
||||||
i18n.ts # Lingui runtime setup
|
i18n.ts # Lingui runtime setup
|
||||||
public/ # Static assets (favicon, manifest, service worker)
|
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",
|
"image/webp",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
||||||
export const DUMP_ALLOWED_MIME_PREFIXES = [
|
export const DUMP_ALLOWED_MIME_PREFIXES = [
|
||||||
"text/",
|
"text/",
|
||||||
"image/",
|
"image/",
|
||||||
@@ -76,22 +75,6 @@ export const PAGINATION_MAX_LIMIT = 100;
|
|||||||
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
|
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
|
||||||
export const UNUSED_INVITES_RETENTION_DAYS = 7;
|
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
|
// SEO/OG
|
||||||
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
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 type { Context } from "@oak/oak";
|
||||||
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
||||||
import {
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "./api-exception.ts";
|
||||||
APIException,
|
import type { AuthPayload } from "../model/auth.ts";
|
||||||
type AuthPayload,
|
import type { Role } from "../../shared/user.ts";
|
||||||
type Role,
|
import { getSessionState } from "../services/user-service.ts";
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import { db } from "../model/db.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks a signature-verified token against the live user row: the user must
|
* Checks a signature-verified token against the live user row: the user must
|
||||||
@@ -21,19 +19,15 @@ function checkTokenOwner(
|
|||||||
userId: string,
|
userId: string,
|
||||||
iat: number | undefined,
|
iat: number | undefined,
|
||||||
): { role: Role } | null {
|
): { role: Role } | null {
|
||||||
const row = db.prepare(
|
const state = getSessionState(userId);
|
||||||
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
|
if (!state) return null;
|
||||||
).get(userId) as
|
if (state.tokensValidAfter) {
|
||||||
| { role: Role; tokens_valid_after: string | null }
|
|
||||||
| undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
if (row.tokens_valid_after) {
|
|
||||||
const cutoff = Math.floor(
|
const cutoff = Math.floor(
|
||||||
new Date(row.tokens_valid_after).getTime() / 1000,
|
new Date(state.tokensValidAfter).getTime() / 1000,
|
||||||
);
|
);
|
||||||
if ((iat ?? 0) < cutoff) return null;
|
if ((iat ?? 0) < cutoff) return null;
|
||||||
}
|
}
|
||||||
return { role: row.role };
|
return { role: state.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
isAuthPayload,
|
isAuthPayload,
|
||||||
isPasswordResetPayload,
|
isPasswordResetPayload,
|
||||||
type PasswordResetPayload,
|
type PasswordResetPayload,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/auth.ts";
|
||||||
import { JWT_SECRET } from "../config.ts";
|
import { JWT_SECRET } from "../config.ts";
|
||||||
|
|
||||||
if (!JWT_SECRET) {
|
if (!JWT_SECRET) {
|
||||||
|
|||||||
@@ -1,40 +1,5 @@
|
|||||||
import { type AuthPayload, type Role } from "../model/interfaces.ts";
|
import { hasPermission, type Permission } from "../../shared/permissions.ts";
|
||||||
|
import type { AuthPayload } from "../model/auth.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||||
return hasPermission(payload.role, permission);
|
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. */
|
/** A playlist's dump, with what deciding who may see it takes. */
|
||||||
export interface PlaylistMember {
|
export interface PlaylistMember {
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
|
// 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.
|
// prod, public/ in dev) — see that script for how the output dir is chosen.
|
||||||
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
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 PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
|
||||||
const APPLE_SIZE = 180;
|
const APPLE_SIZE = 180;
|
||||||
const ICO_SIZES = [16, 32, 48];
|
const ICO_SIZES = [16, 32, 48];
|
||||||
@@ -35,34 +34,6 @@ function ensureWasm(): Promise<unknown> {
|
|||||||
return wasmReady;
|
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 {
|
function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||||
const resvg = new Resvg(svg, {
|
const resvg = new Resvg(svg, {
|
||||||
fitTo: { mode: "width", value: size },
|
fitTo: { mode: "width", value: size },
|
||||||
@@ -143,7 +114,7 @@ export async function generateSiteIcons(
|
|||||||
opts: GenerateSiteIconsOptions,
|
opts: GenerateSiteIconsOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { emoji, outDir, siteName, bgColor } = opts;
|
const { emoji, outDir, siteName, bgColor } = opts;
|
||||||
const svg = await fetchGlyph(emoji);
|
const svg = await fetchTwemojiSvg(emoji, emojiToCodepoint(emoji));
|
||||||
await ensureWasm();
|
await ensureWasm();
|
||||||
|
|
||||||
await Deno.mkdir(outDir, { recursive: true });
|
await Deno.mkdir(outDir, { recursive: true });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "@oak/oak";
|
import type { Context } from "@oak/oak";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
|
import { APIException } from "./api-exception.ts";
|
||||||
import { serveFile } from "./serve-file.ts";
|
import { serveFile } from "./serve-file.ts";
|
||||||
import {
|
import {
|
||||||
ALLOWED_IMAGE_MIMES,
|
ALLOWED_IMAGE_MIMES,
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import {
|
|||||||
type State,
|
type State,
|
||||||
} from "@oak/oak";
|
} from "@oak/oak";
|
||||||
import { authenticate } from "../lib/auth.ts";
|
import { authenticate } from "../lib/auth.ts";
|
||||||
import {
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { AuthPayload } from "../model/auth.ts";
|
||||||
type AuthPayload,
|
import { can } from "../lib/permissions.ts";
|
||||||
} from "../model/interfaces.ts";
|
import type { Permission } from "../../shared/permissions.ts";
|
||||||
import { can, type Permission } from "../lib/permissions.ts";
|
|
||||||
|
|
||||||
export interface AuthState extends State {
|
export interface AuthState extends State {
|
||||||
user: AuthPayload;
|
user: AuthPayload;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Context, Next } from "@oak/oak";
|
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) {
|
export async function errorMiddleware(ctx: Context, next: Next) {
|
||||||
try {
|
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 { APIErrorCode } from "../../shared/api.ts";
|
||||||
import {
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
fetchWithTimeout,
|
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
|
||||||
isValidHttpUrl,
|
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
|
||||||
readTextCapped,
|
import type { Tralbum } from "../../shared/player.ts";
|
||||||
} from "./rich-content-service.ts";
|
|
||||||
import {
|
|
||||||
BANDCAMP_HOST_RE,
|
|
||||||
parseTralbum,
|
|
||||||
type Tralbum,
|
|
||||||
} from "./bandcamp-tralbum.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a Bandcamp page to its streamable tracks, at play time.
|
* 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.
|
* 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
|
/** 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
|
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
|
||||||
* match this and keep using the embed. */
|
* match this and keep using the embed. */
|
||||||
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
|
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.
|
* 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
|
* replaces `&` before `"`, so an escaped-ampersand-then-quot sequence
|
||||||
* (`&quot;`, i.e. the literal text `"` inside a title) decodes twice
|
* (`&quot;`, i.e. the literal text `"` inside a title) decodes twice
|
||||||
* and yields a bare `"` — which lands inside the JSON string and breaks the
|
* 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 { RichContent } from "../../../shared/dump.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "./provider.ts";
|
||||||
import {
|
import { extractOgTag } from "../html.ts";
|
||||||
extractOgTag,
|
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||||
fetchWithTimeout,
|
|
||||||
readTextCapped,
|
|
||||||
} from "../rich-content-service.ts";
|
|
||||||
|
|
||||||
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RichContent } from "../../model/interfaces.ts";
|
import type { RichContent } from "../../../shared/dump.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "./provider.ts";
|
||||||
import {
|
import {
|
||||||
extractBestIcon,
|
extractBestIcon,
|
||||||
extractFirstContentImage,
|
extractFirstContentImage,
|
||||||
@@ -10,10 +10,9 @@ import {
|
|||||||
extractOgTag,
|
extractOgTag,
|
||||||
extractPageTitle,
|
extractPageTitle,
|
||||||
extractThemeColor,
|
extractThemeColor,
|
||||||
fetchWithTimeout,
|
|
||||||
normalizeCssColor,
|
normalizeCssColor,
|
||||||
readTextCapped,
|
} from "../html.ts";
|
||||||
} from "../rich-content-service.ts";
|
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||||
|
|
||||||
export const genericProvider: RichContentProvider = {
|
export const genericProvider: RichContentProvider = {
|
||||||
name: "generic",
|
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 { RichContent } from "../../../shared/dump.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "./provider.ts";
|
||||||
import { getDump } from "../dump-service.ts";
|
import { getDump } from "../../services/dump-service.ts";
|
||||||
import { getUserByUsername } from "../user-service.ts";
|
import { getUserByUsername } from "../../services/user-service.ts";
|
||||||
import { getPlaylistById } from "../playlist-service.ts";
|
import { getPlaylistById } from "../../services/playlist-service.ts";
|
||||||
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||||
import { isSelfUrl } from "../../lib/self-url.ts";
|
import { isSelfUrl } from "../../lib/self-url.ts";
|
||||||
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { RichContent } from "../../model/interfaces.ts";
|
import type { RichContent } from "../../../shared/dump.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "./provider.ts";
|
||||||
import {
|
import { extractOgTag } from "../html.ts";
|
||||||
extractOgTag,
|
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||||
fetchWithTimeout,
|
|
||||||
readTextCapped,
|
|
||||||
} from "../rich-content-service.ts";
|
|
||||||
|
|
||||||
export const soundcloudProvider: RichContentProvider = {
|
export const soundcloudProvider: RichContentProvider = {
|
||||||
name: "soundcloud",
|
name: "soundcloud",
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { RichContent } from "../../model/interfaces.ts";
|
import type { RichContent } from "../../../shared/dump.ts";
|
||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "./provider.ts";
|
||||||
import {
|
import { extractOgTag } from "../html.ts";
|
||||||
extractOgTag,
|
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||||
fetchWithTimeout,
|
|
||||||
readTextCapped,
|
|
||||||
} from "../rich-content-service.ts";
|
|
||||||
|
|
||||||
function extractVideoId(url: string): string | null {
|
function extractVideoId(url: string): string | null {
|
||||||
try {
|
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 { APIErrorCode } from "../../shared/api.ts";
|
||||||
import { readTextCapped } from "./rich-content-service.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.
|
* 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.
|
* 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. */
|
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
|
||||||
const TTL_MS = 6 * 60 * 60 * 1000;
|
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
const MAX_ENTRIES = 200;
|
const MAX_ENTRIES = 200;
|
||||||
@@ -4,12 +4,9 @@ import {
|
|||||||
createAttachment,
|
createAttachment,
|
||||||
getAttachment,
|
getAttachment,
|
||||||
} from "../services/attachment-service.ts";
|
} from "../services/attachment-service.ts";
|
||||||
import {
|
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { AttachmentResponse } from "../../shared/dump.ts";
|
||||||
type APIResponse,
|
|
||||||
type AttachmentResponse,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import {
|
import {
|
||||||
ATTACHMENTS_DIR,
|
ATTACHMENTS_DIR,
|
||||||
serveUploadedFile,
|
serveUploadedFile,
|
||||||
|
|||||||
@@ -5,14 +5,10 @@ import {
|
|||||||
broadcastUserUpdated,
|
broadcastUserUpdated,
|
||||||
updateClientAvatar,
|
updateClientAvatar,
|
||||||
} from "../services/ws-service.ts";
|
} from "../services/ws-service.ts";
|
||||||
import {
|
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { SelfUser } from "../../shared/user.ts";
|
||||||
type APIResponse,
|
import { toPublicUser, toSelfUser } from "../model/user.ts";
|
||||||
type SelfUser,
|
|
||||||
toPublicUser,
|
|
||||||
toSelfUser,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import {
|
import {
|
||||||
AVATARS_DIR,
|
AVATARS_DIR,
|
||||||
serveUploadedFile,
|
serveUploadedFile,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import type { APIResponse } from "../model/interfaces.ts";
|
import type { APIResponse } from "../../shared/api.ts";
|
||||||
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
|
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||||
import type { Tralbum } from "../services/bandcamp-tralbum.ts";
|
import type { Tralbum } from "../../shared/player.ts";
|
||||||
|
|
||||||
const bandcampRouter = new Router();
|
const bandcampRouter = new Router();
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { Router } from "@oak/oak";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Category,
|
type PaginatedData,
|
||||||
type Dump,
|
} 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,
|
isCreateCategoryRequest,
|
||||||
isUpdateCategoryRequest,
|
isUpdateCategoryRequest,
|
||||||
type PaginatedData,
|
} from "../validators/category.ts";
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
|
|
||||||
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
|
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
|
||||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from "@oak/oak";
|
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 { authMiddleware } from "../middleware/auth.ts";
|
||||||
import {
|
import {
|
||||||
getChatMessagesAfter,
|
getChatMessagesAfter,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
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,
|
isCreateCommentRequest,
|
||||||
isUpdateCommentRequest,
|
isUpdateCommentRequest,
|
||||||
type PaginatedData,
|
} from "../validators/comment.ts";
|
||||||
type PublicUser,
|
import type { PublicUser } from "../../shared/user.ts";
|
||||||
toPublicUser,
|
import { toPublicUser } from "../model/user.ts";
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { can } from "../lib/permissions.ts";
|
import { can } from "../lib/permissions.ts";
|
||||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ import { Router } from "@oak/oak";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Dump,
|
type PaginatedData,
|
||||||
type DumpUrlMatch,
|
} from "../../shared/api.ts";
|
||||||
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
|
import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
|
||||||
|
import {
|
||||||
isCreateUrlDumpRequest,
|
isCreateUrlDumpRequest,
|
||||||
isUpdateDumpRequest,
|
isUpdateDumpRequest,
|
||||||
type PaginatedData,
|
} from "../validators/dump.ts";
|
||||||
type Playlist,
|
import type { Playlist } from "../../shared/playlist.ts";
|
||||||
type PublicUser,
|
import type { PublicUser } from "../../shared/user.ts";
|
||||||
toPublicUser,
|
import { toPublicUser } from "../model/user.ts";
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
|
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { can } from "../lib/permissions.ts";
|
import { can } from "../lib/permissions.ts";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from "@oak/oak";
|
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 { DUMPS_DIR } from "../lib/upload.ts";
|
||||||
import { authenticateMedia } from "../lib/auth.ts";
|
import { authenticateMedia } from "../lib/auth.ts";
|
||||||
import { serveFile } from "../lib/serve-file.ts";
|
import { serveFile } from "../lib/serve-file.ts";
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import {
|
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
|
||||||
type APIResponse,
|
import type { Dump } from "../../shared/dump.ts";
|
||||||
type Dump,
|
import type { FollowStatus } from "../../shared/follow.ts";
|
||||||
type FollowStatus,
|
|
||||||
type PaginatedData,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import { type Page, parsePagination } from "../lib/pagination.ts";
|
import { type Page, parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
followPlaylist,
|
followPlaylist,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { InviteResponse } from "../../shared/user.ts";
|
||||||
type APIResponse,
|
|
||||||
type InviteResponse,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Notification,
|
|
||||||
type PaginatedData,
|
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 { parsePagination } from "../lib/pagination.ts";
|
||||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
|
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||||
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
|
||||||
isCreatePlaylistRequest,
|
isCreatePlaylistRequest,
|
||||||
isReorderPlaylistRequest,
|
isReorderPlaylistRequest,
|
||||||
isUpdatePlaylistRequest,
|
isUpdatePlaylistRequest,
|
||||||
type Playlist,
|
} from "../validators/playlist.ts";
|
||||||
type PlaylistMembership,
|
import type {
|
||||||
type PlaylistWithDumps,
|
Playlist,
|
||||||
} from "../model/interfaces.ts";
|
PlaylistMembership,
|
||||||
|
PlaylistWithDumps,
|
||||||
|
} from "../../shared/playlist.ts";
|
||||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||||
import { can } from "../lib/permissions.ts";
|
import { can } from "../lib/permissions.ts";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||||
fetchWithTimeout,
|
import { fetchProxiedImage } from "../remote/image-proxy.ts";
|
||||||
isValidHttpUrl,
|
import { tryFetchRichContent } from "../services/rich-content-service.ts";
|
||||||
tryFetchRichContent,
|
|
||||||
} from "../services/rich-content-service.ts";
|
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
type APIFailure,
|
type APIFailure,
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type LinkPreview,
|
} from "../../shared/api.ts";
|
||||||
} from "../model/interfaces.ts";
|
import type { LinkPreview } from "../../shared/dump.ts";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
|
||||||
const previewRouter = new Router();
|
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
|
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
||||||
// use the server to fetch pages for them.
|
// use the server to fetch pages for them.
|
||||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||||
@@ -50,59 +37,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
|||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Proxied images, by URL: every viewer of a feed asks for the same
|
|
||||||
// thumbnails, which were fetched from their hosts again for each one. Bounded
|
|
||||||
// by total size; the least recently used go first.
|
|
||||||
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
||||||
const imageCache = new Map<
|
|
||||||
string,
|
|
||||||
{ bytes: Uint8Array; contentType: string; at: number }
|
|
||||||
>();
|
|
||||||
let imageCacheBytes = 0;
|
|
||||||
|
|
||||||
function readImageCache(
|
|
||||||
url: string,
|
|
||||||
): { bytes: Uint8Array; contentType: string } | undefined {
|
|
||||||
const hit = imageCache.get(url);
|
|
||||||
if (!hit) return undefined;
|
|
||||||
imageCache.delete(url);
|
|
||||||
if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) {
|
|
||||||
imageCacheBytes -= hit.bytes.byteLength;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
imageCache.set(url, hit); // most recently used
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeImageCache(
|
|
||||||
url: string,
|
|
||||||
bytes: Uint8Array,
|
|
||||||
contentType: string,
|
|
||||||
): void {
|
|
||||||
const old = imageCache.get(url);
|
|
||||||
if (old) {
|
|
||||||
imageCache.delete(url);
|
|
||||||
imageCacheBytes -= old.bytes.byteLength;
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES &&
|
|
||||||
imageCache.size > 0
|
|
||||||
) {
|
|
||||||
const [oldestUrl, oldest] = imageCache.entries().next().value!;
|
|
||||||
imageCache.delete(oldestUrl);
|
|
||||||
imageCacheBytes -= oldest.bytes.byteLength;
|
|
||||||
}
|
|
||||||
imageCache.set(url, { bytes, contentType, at: Date.now() });
|
|
||||||
imageCacheBytes += bytes.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget every proxied image (tests). */
|
|
||||||
export function clearImageCache(): void {
|
|
||||||
imageCache.clear();
|
|
||||||
imageCacheBytes = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendImage(
|
function sendImage(
|
||||||
ctx: { response: { headers: Headers; body?: unknown } },
|
ctx: { response: { headers: Headers; body?: unknown } },
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
@@ -129,41 +63,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = readImageCache(url);
|
|
||||||
if (cached) {
|
|
||||||
sendImage(ctx, cached.bytes, cached.contentType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url, 8000);
|
const image = await fetchProxiedImage(url);
|
||||||
// Raster formats only. The bytes go out under this site's origin, so an
|
if (!image) {
|
||||||
// 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();
|
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sendImage(ctx, image.bytes, image.contentType);
|
||||||
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);
|
|
||||||
} catch {
|
} catch {
|
||||||
ctx.response.status = 502;
|
ctx.response.status = 502;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { parsePagination } from "../lib/pagination.ts";
|
|||||||
import { searchDumps } from "../services/dump-service.ts";
|
import { searchDumps } from "../services/dump-service.ts";
|
||||||
import { searchUsersGlobal } from "../services/user-service.ts";
|
import { searchUsersGlobal } from "../services/user-service.ts";
|
||||||
import { searchPlaylists } from "../services/playlist-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" });
|
const router = new Router({ prefix: "/api/search" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from "@oak/oak";
|
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 {
|
import {
|
||||||
dumpFileVersion,
|
dumpFileVersion,
|
||||||
getDump,
|
getDump,
|
||||||
|
|||||||
@@ -2,28 +2,30 @@ import { Router } from "@oak/oak";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
|
type PaginatedData,
|
||||||
|
} from "../../shared/api.ts";
|
||||||
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
|
import {
|
||||||
type AuthResponse,
|
type AuthResponse,
|
||||||
type AuthTokens,
|
type AuthTokens,
|
||||||
type Dump,
|
|
||||||
fieldOf,
|
|
||||||
type Invitee,
|
type Invitee,
|
||||||
isLoginUserRequest,
|
|
||||||
isRegisterUserRequest,
|
|
||||||
isRole,
|
isRole,
|
||||||
isUpdateUserRequest,
|
|
||||||
type MediaTokenResponse,
|
type MediaTokenResponse,
|
||||||
type PaginatedData,
|
|
||||||
type Playlist,
|
|
||||||
type PublicUser,
|
type PublicUser,
|
||||||
type SelfUser,
|
type SelfUser,
|
||||||
toPublicUser,
|
|
||||||
toSelfUser,
|
|
||||||
type User,
|
|
||||||
type UserSuggestion,
|
type UserSuggestion,
|
||||||
|
} from "../../shared/user.ts";
|
||||||
|
import type { Dump } from "../../shared/dump.ts";
|
||||||
|
import {
|
||||||
|
fieldOf,
|
||||||
|
isLoginUserRequest,
|
||||||
|
isRegisterUserRequest,
|
||||||
|
isUpdateUserRequest,
|
||||||
validateRegisterUserRequest,
|
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 { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
|
||||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||||
@@ -48,12 +50,8 @@ import {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
} from "../services/password-reset-service.ts";
|
} from "../services/password-reset-service.ts";
|
||||||
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
|
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
|
||||||
import {
|
import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
|
||||||
FROM_EMAIL,
|
import { VALIDATION } from "../../shared/validation.ts";
|
||||||
OG_SITE_NAME,
|
|
||||||
VALIDATION,
|
|
||||||
WELCOME_EMAIL_BODY,
|
|
||||||
} from "../config.ts";
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ import {
|
|||||||
markChatRead,
|
markChatRead,
|
||||||
updateChatMessage,
|
updateChatMessage,
|
||||||
} from "../services/chat-service.ts";
|
} from "../services/chat-service.ts";
|
||||||
import { notifyMentions } from "../services/notification-service.ts";
|
import {
|
||||||
import { hasPermission } from "../lib/permissions.ts";
|
getUnreadCount,
|
||||||
|
notifyMentions,
|
||||||
|
} from "../services/notification-service.ts";
|
||||||
|
import { hasPermission } from "../../shared/permissions.ts";
|
||||||
import {
|
import {
|
||||||
castVote,
|
castVote,
|
||||||
getUserVotes,
|
getUserVotes,
|
||||||
@@ -39,14 +42,13 @@ import {
|
|||||||
getUserCommentLikes,
|
getUserCommentLikes,
|
||||||
removeCommentLike,
|
removeCommentLike,
|
||||||
} from "../services/comment-like-service.ts";
|
} from "../services/comment-like-service.ts";
|
||||||
import { getUnreadCount } from "../services/notification-service.ts";
|
|
||||||
import { getUserById } from "../services/user-service.ts";
|
import { getUserById } from "../services/user-service.ts";
|
||||||
import {
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type {
|
||||||
type ClientToServerMessage,
|
ClientToServerMessage,
|
||||||
type ServerToClientMessage,
|
ServerToClientMessage,
|
||||||
type User,
|
} from "../../shared/ws.ts";
|
||||||
} from "../model/interfaces.ts";
|
import type { User } from "../model/user.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import type { APIResponse } from "../model/interfaces.ts";
|
import type { APIResponse } from "../../shared/api.ts";
|
||||||
import {
|
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||||
resolveYouTubePlaylist,
|
import type { YouTubePlaylist } from "../../shared/player.ts";
|
||||||
type YouTubePlaylist,
|
|
||||||
} from "../services/youtube-playlist-service.ts";
|
|
||||||
|
|
||||||
const youtubeRouter = new Router();
|
const youtubeRouter = new Router();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db, transaction } from "../model/db.ts";
|
import { db, transaction } from "../db/connection.ts";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
|
|
||||||
export function createAttachment(id: string, mime: string): void {
|
export function createAttachment(id: string, mime: string): void {
|
||||||
db.prepare(
|
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 {
|
import {
|
||||||
db,
|
|
||||||
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
transaction,
|
} from "../model/dump.ts";
|
||||||
} from "../model/db.ts";
|
|
||||||
import { UUID_RE } from "../lib/slugify.ts";
|
import { UUID_RE } from "../lib/slugify.ts";
|
||||||
|
|
||||||
// Matches http(s) URLs in free text — stops at whitespace or markdown-link
|
// 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 {
|
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,
|
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isCategoryRow,
|
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
transaction,
|
} from "../model/dump.ts";
|
||||||
} from "../model/db.ts";
|
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||||
import { VALIDATION } from "../config.ts";
|
import { VALIDATION } from "../../shared/validation.ts";
|
||||||
|
|
||||||
function validateSlug(slug: string): void {
|
function validateSlug(slug: string): void {
|
||||||
if (!isValidSlugFormat(slug)) {
|
if (!isValidSlugFormat(slug)) {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { ChatMessage } from "../../shared/chat.ts";
|
||||||
type ChatMessage,
|
import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
|
||||||
} from "../model/interfaces.ts";
|
import { db } from "../db/connection.ts";
|
||||||
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
|
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
import { VALIDATION } from "../../shared/validation.ts";
|
||||||
|
|
||||||
export const MAX_CHAT_LENGTH = 2000;
|
|
||||||
const DEFAULT_LIMIT = 50;
|
const DEFAULT_LIMIT = 50;
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
|
|
||||||
@@ -51,11 +50,11 @@ export function createChatMessage(
|
|||||||
"Message cannot be empty",
|
"Message cannot be empty",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.length > MAX_CHAT_LENGTH) {
|
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
400,
|
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
|
// 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",
|
"Message cannot be empty",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (trimmed.length > MAX_CHAT_LENGTH) {
|
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
400,
|
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 = ?;`)
|
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 { APIErrorCode } from "../../shared/api.ts";
|
||||||
import { db, isUserRow, userRowToApi } from "../model/db.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 { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import {
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { Comment } from "../../shared/comment.ts";
|
||||||
type Comment,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import { type SQLOutputValue } from "node:sqlite";
|
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 {
|
import {
|
||||||
notifyDumpOwnerNewComment,
|
notifyDumpOwnerNewComment,
|
||||||
notifyMentions,
|
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 {
|
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 as SELECT_COLS,
|
||||||
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
||||||
dumpApiToRow,
|
dumpApiToRow,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
transaction,
|
} from "../model/dump.ts";
|
||||||
} from "../model/db.ts";
|
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
fetchRichContent,
|
fetchRichContent,
|
||||||
isValidHttpUrl,
|
|
||||||
tryFetchRichContent,
|
tryFetchRichContent,
|
||||||
} from "./rich-content-service.ts";
|
} from "./rich-content-service.ts";
|
||||||
|
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||||
import {
|
import {
|
||||||
broadcastDumpDeleted,
|
broadcastDumpDeleted,
|
||||||
broadcastDumpUpdated,
|
broadcastDumpUpdated,
|
||||||
@@ -35,11 +34,13 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
|||||||
import {
|
import {
|
||||||
DUMP_ALLOWED_MIME_PREFIXES,
|
DUMP_ALLOWED_MIME_PREFIXES,
|
||||||
DUMP_ALLOWED_MIME_TYPES,
|
DUMP_ALLOWED_MIME_TYPES,
|
||||||
DUMP_MAX_FILE_SIZE_BYTES,
|
|
||||||
DUMPS_DIR,
|
DUMPS_DIR,
|
||||||
THUMBNAILS_DIR,
|
THUMBNAILS_DIR,
|
||||||
VALIDATION,
|
|
||||||
} from "../config.ts";
|
} from "../config.ts";
|
||||||
|
import {
|
||||||
|
DUMP_MAX_FILE_SIZE_BYTES,
|
||||||
|
VALIDATION,
|
||||||
|
} from "../../shared/validation.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
import { relinkBacklinks } from "./backlink-service.ts";
|
import { relinkBacklinks } from "./backlink-service.ts";
|
||||||
import {
|
import {
|
||||||
@@ -240,7 +241,7 @@ export async function createFileDump(
|
|||||||
/**
|
/**
|
||||||
* A file dump's version as the app puts it in file and thumbnail URLs
|
* 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
|
* (`?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(
|
export function dumpFileVersion(
|
||||||
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import {
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
APIErrorCode,
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
APIException,
|
import type { Dump } from "../../shared/dump.ts";
|
||||||
type Dump,
|
import type { FollowStatus } from "../../shared/follow.ts";
|
||||||
type FollowStatus,
|
import type { Playlist } from "../../shared/playlist.ts";
|
||||||
type Playlist,
|
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
|
||||||
type User,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import {
|
import {
|
||||||
notifyPlaylistOwnerNewFollower,
|
notifyPlaylistOwnerNewFollower,
|
||||||
notifyUserNewFollower,
|
notifyUserNewFollower,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
|
import { db } from "../db/connection.ts";
|
||||||
import {
|
import {
|
||||||
db,
|
|
||||||
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
isFollowRow,
|
} from "../model/dump.ts";
|
||||||
isPlaylistRow,
|
import { isFollowRow } from "../model/follow.ts";
|
||||||
isUserRow,
|
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||||
playlistRowToApi,
|
|
||||||
userRowToApi,
|
|
||||||
} from "../model/db.ts";
|
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { attachCategoryIds } from "./category-service.ts";
|
import { attachCategoryIds } from "./category-service.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
import { db, isInviteRow } from "../model/db.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";
|
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
||||||
|
|
||||||
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
|
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
|
||||||
|
|||||||
@@ -3,20 +3,17 @@ import type {
|
|||||||
NotificationData,
|
NotificationData,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
UserDumpPostedData,
|
UserDumpPostedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../../shared/notification.ts";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
|
import { db, transaction } from "../db/connection.ts";
|
||||||
import {
|
import {
|
||||||
db,
|
|
||||||
isNotificationRow,
|
isNotificationRow,
|
||||||
notificationRowToApi,
|
notificationRowToApi,
|
||||||
transaction,
|
} from "../model/notification.ts";
|
||||||
} from "../model/db.ts";
|
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { sendToUser } from "./ws-service.ts";
|
import { sendToUser } from "./ws-service.ts";
|
||||||
|
import { MENTION_RE } from "../../shared/mention.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;
|
|
||||||
|
|
||||||
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -28,7 +25,8 @@ function createNotification(
|
|||||||
sourceKey: string | null = null,
|
sourceKey: string | null = null,
|
||||||
): void {
|
): void {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const createdAt = new Date().toISOString();
|
const now = new Date();
|
||||||
|
const createdAt = now.toISOString();
|
||||||
const dataJson = JSON.stringify(data);
|
const dataJson = JSON.stringify(data);
|
||||||
|
|
||||||
let changes: number;
|
let changes: number;
|
||||||
@@ -51,7 +49,14 @@ function createNotification(
|
|||||||
if (changes > 0) {
|
if (changes > 0) {
|
||||||
sendToUser(userId, {
|
sendToUser(userId, {
|
||||||
type: "notification_created",
|
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,
|
dumpTitle,
|
||||||
};
|
};
|
||||||
const dataJson = JSON.stringify(data);
|
const dataJson = JSON.stringify(data);
|
||||||
const createdAt = new Date().toISOString();
|
const now = new Date();
|
||||||
|
const createdAt = now.toISOString();
|
||||||
const sourceKey = `dump:${dumpId}`;
|
const sourceKey = `dump:${dumpId}`;
|
||||||
|
|
||||||
// Insert per follower so we can broadcast only the rows that were actually
|
// Insert per follower so we can broadcast only the rows that were actually
|
||||||
@@ -201,7 +207,7 @@ export function notifyUserFollowersNewDump(
|
|||||||
type: "user_dump_posted",
|
type: "user_dump_posted",
|
||||||
data,
|
data,
|
||||||
read: false,
|
read: false,
|
||||||
createdAt,
|
createdAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
import { db, isPasswordResetTokenRow } from "../model/db.ts";
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
|
import { db } from "../db/connection.ts";
|
||||||
|
import { isPasswordResetTokenRow } from "../model/password-reset.ts";
|
||||||
import {
|
import {
|
||||||
createPasswordResetToken,
|
createPasswordResetToken,
|
||||||
verifyPasswordResetToken,
|
verifyPasswordResetToken,
|
||||||
} from "../lib/jwt.ts";
|
} from "../lib/jwt.ts";
|
||||||
import { getUserByEmail, updateUser } from "./user-service.ts";
|
import { getUserByEmail, updateUser } from "./user-service.ts";
|
||||||
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
||||||
import {
|
import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME } from "../config.ts";
|
||||||
FROM_EMAIL,
|
import { VALIDATION } from "../../shared/validation.ts";
|
||||||
FRONTEND_URL,
|
|
||||||
OG_SITE_NAME,
|
|
||||||
VALIDATION,
|
|
||||||
} from "../config.ts";
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
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 {
|
import {
|
||||||
APIErrorCode,
|
|
||||||
APIException,
|
|
||||||
type CreatePlaylistRequest,
|
|
||||||
type Dump,
|
|
||||||
type Playlist,
|
|
||||||
type PlaylistMembership,
|
|
||||||
type PlaylistWithDumps,
|
|
||||||
type UpdatePlaylistRequest,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
DUMP_COLUMNS_ALIASED,
|
DUMP_COLUMNS_ALIASED,
|
||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
isPlaylistRow,
|
} from "../model/dump.ts";
|
||||||
playlistRowToApi,
|
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||||
transaction,
|
|
||||||
} from "../model/db.ts";
|
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
broadcastPlaylistCreated,
|
broadcastPlaylistCreated,
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import type { RichContent } from "../model/interfaces.ts";
|
import type { RichContent } from "../../shared/dump.ts";
|
||||||
import { youtubeProvider } from "./providers/youtube.ts";
|
import type { RichContentProvider } from "../remote/providers/provider.ts";
|
||||||
import { bandcampProvider } from "./providers/bandcamp.ts";
|
import { youtubeProvider } from "../remote/providers/youtube.ts";
|
||||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
|
||||||
import { selfProvider } from "./providers/self.ts";
|
import { soundcloudProvider } from "../remote/providers/soundcloud.ts";
|
||||||
import { genericProvider } from "./providers/generic.ts";
|
import { selfProvider } from "../remote/providers/self.ts";
|
||||||
import {
|
import { genericProvider } from "../remote/providers/generic.ts";
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register providers in priority order. The first match wins.
|
* Register providers in priority order. The first match wins.
|
||||||
@@ -29,651 +19,6 @@ const providers: RichContentProvider[] = [
|
|||||||
genericProvider,
|
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 {
|
export interface FetchRichContentResult {
|
||||||
/** False when the page couldn't be reached at all, as opposed to reached and
|
/** 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
|
* 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 {
|
import {
|
||||||
APIErrorCode,
|
isUserRow,
|
||||||
APIException,
|
|
||||||
type Invitee,
|
|
||||||
type PublicUser,
|
|
||||||
type RegisterUserRequest,
|
|
||||||
toPublicUser,
|
toPublicUser,
|
||||||
type UpdateUserRequest,
|
|
||||||
type User,
|
type User,
|
||||||
type UserSuggestion,
|
userApiToRow,
|
||||||
} from "../model/interfaces.ts";
|
userRowToApi,
|
||||||
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
} from "../model/user.ts";
|
||||||
|
import { db } from "../db/connection.ts";
|
||||||
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
||||||
|
|
||||||
import { hashPassword } from "../lib/jwt.ts";
|
import { hashPassword } from "../lib/jwt.ts";
|
||||||
@@ -92,6 +98,25 @@ export function getUserById(userId: string): User {
|
|||||||
return userRowToApi(userRow);
|
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 {
|
export function getUserByUsername(username: string): User {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`${USER_SELECT} WHERE u.username = ?`,
|
`${USER_SELECT} WHERE u.username = ?`,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../../shared/api.ts";
|
||||||
import { db, isUserRow, userRowToApi } from "../model/db.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 { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type {
|
import type { ChatMessage } from "../../shared/chat.ts";
|
||||||
ChatMessage,
|
import type { Comment } from "../../shared/comment.ts";
|
||||||
Comment,
|
import type { Dump } from "../../shared/dump.ts";
|
||||||
Dump,
|
import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
|
||||||
OnlineUser,
|
import type { Playlist } from "../../shared/playlist.ts";
|
||||||
Playlist,
|
import type { PublicUser, Role } from "../../shared/user.ts";
|
||||||
PublicUser,
|
|
||||||
Role,
|
|
||||||
ServerToClientMessage,
|
|
||||||
} from "../model/interfaces.ts";
|
|
||||||
import {
|
import {
|
||||||
isMemberVisibleTo,
|
isMemberVisibleTo,
|
||||||
type PlaylistMember,
|
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 { stub } from "@std/testing/mock";
|
||||||
import { app } from "../main.ts";
|
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 { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
||||||
import { makeSlug } from "../lib/slugify.ts";
|
import { makeSlug } from "../lib/slugify.ts";
|
||||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||||
import { clearRichContentCache } from "../services/rich-content-service.ts";
|
import { clearRichContentCache } from "../services/rich-content-service.ts";
|
||||||
import { clearImageCache } from "../routes/preview.ts";
|
import { clearImageCache } from "../remote/image-proxy.ts";
|
||||||
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
|
import type { APIError, APIResponse } from "../../shared/api.ts";
|
||||||
|
import type { Role } from "../../shared/user.ts";
|
||||||
|
|
||||||
export { app, db, TEST_DIR };
|
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 { assertEquals } from "@std/assert";
|
||||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
import { canonicalizeUrl } from "../../lib/canonical-url.ts";
|
||||||
import { up as up0013 } from "../db/migrations/0013_dump_url_canonical.ts";
|
|
||||||
import { DatabaseSync } from "node:sqlite";
|
|
||||||
|
|
||||||
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
|
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
|
||||||
const want = "https://example.com/a";
|
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("not a url"), null);
|
||||||
assertEquals(canonicalizeUrl(""), 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