Compare commits
2 Commits
d392be7cb8
...
d62e4a9883
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62e4a9883 | ||
|
|
1702370447 |
45
.gitea/workflows/docs.yaml
Normal file
45
.gitea/workflows/docs.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Build and deploy docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "api/**"
|
||||
- "shared/**"
|
||||
- "mod.ts"
|
||||
- "deno.json"
|
||||
- "deno.lock"
|
||||
- ".gitea/workflows/docs.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Same Deno as the Docker image (Dockerfile).
|
||||
DENO_VERSION: "2.9.0"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: ${{ env.DENO_VERSION }}
|
||||
|
||||
- name: Build docs
|
||||
# `deno doc` writes relative links, so the site works under whatever
|
||||
# path the gitea-pages proxy serves it from
|
||||
# (https://pub.sqrt.fr/<owner>/<repo>/).
|
||||
run: deno task doc
|
||||
|
||||
- name: Deploy docs
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
force_orphan: true
|
||||
publish_branch: gh-pages
|
||||
publish_dir: ./docs
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -178,3 +178,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# deno task doc
|
||||
docs/
|
||||
|
||||
18
README.md
18
README.md
@@ -158,6 +158,24 @@ 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`.
|
||||
|
||||
The backend is documented for `deno doc`, the same way in every file. Each
|
||||
module opens with a `/** … @module */` block saying what it is for. Every
|
||||
export has a JSDoc comment, and so does every field of an exported interface; a
|
||||
comment that describes a declaration is always `/** */`, and `//` is only for
|
||||
notes on the code inside. Functions open with a verb ("Returns…", "Checks…"),
|
||||
everything else with a noun phrase. Parameters are named in the prose, in
|
||||
backticks. `{@link}` points at symbols the file already has in scope. A
|
||||
function that can throw to its caller says so with `@throws`: for an
|
||||
`APIException`, the statuses and when. A router's JSDoc lists its endpoints, one
|
||||
line each: method and path, who may call it, what it does. `deno task doc`
|
||||
builds the HTML reference into `docs/` from every module of `api/` and
|
||||
`shared/` but the tests. Its index page is the module doc of `mod.ts` at the
|
||||
root, which introduces the repository's three parts and links every documented
|
||||
module (`mod.test.ts` checks it), and the `docs` workflow publishes it on every
|
||||
push to `main` that touches them. `deno task doc:lint` checks
|
||||
the rules the linter can see; `shared/` is not documented yet, so it still
|
||||
fails there.
|
||||
|
||||
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`,
|
||||
|
||||
144
api/config.ts
144
api/config.ts
@@ -1,6 +1,21 @@
|
||||
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
||||
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
|
||||
export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || "";
|
||||
/**
|
||||
* The server's settings: those read from `GERBEUR_*` environment variables,
|
||||
* with their defaults, and the fixed limits the backend applies.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The port the server listens on (`GERBEUR_PORT`, default 8000). */
|
||||
export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
||||
/**
|
||||
* The SMTP server mail is sent through, as an `smtps://` URL
|
||||
* (`GERBEUR_SMTPS_URL`). Empty disables email.
|
||||
*/
|
||||
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
|
||||
"";
|
||||
/** The sender address of outgoing mail (`GERBEUR_FROM_EMAIL`). */
|
||||
export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
|
||||
"";
|
||||
|
||||
const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}!
|
||||
|
||||
@@ -8,53 +23,85 @@ Hi **{{username}}**,
|
||||
|
||||
Your account has been created successfully. Welcome aboard!`;
|
||||
|
||||
export const WELCOME_EMAIL_BODY =
|
||||
/**
|
||||
* The Markdown body of the welcome email (`GERBEUR_WELCOME_EMAIL_BODY`), with
|
||||
* `{{site_name}}` and `{{username}}` placeholders.
|
||||
*/
|
||||
export const WELCOME_EMAIL_BODY: string =
|
||||
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() ||
|
||||
DEFAULT_WELCOME_EMAIL_BODY;
|
||||
export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
|
||||
// GERBEUR_LISTEN_HOST controls the network interface Oak binds to.
|
||||
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
|
||||
// Set to 127.0.0.1 to restrict to loopback only.
|
||||
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
|
||||
// Public-facing URL of the server — used for CORS, WebSocket origin checks,
|
||||
// email links, and OG image URLs. Behind a reverse proxy, set this to the
|
||||
// externally-visible URL (e.g. https://example.com). No trailing slash.
|
||||
// Defaults to http://localhost:PORT for local development.
|
||||
export const PUBLIC_URL =
|
||||
/** The key tokens are signed with (`GERBEUR_JWT_SECRET`). Required. */
|
||||
export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() ||
|
||||
"";
|
||||
/**
|
||||
* The network interface the server binds to (`GERBEUR_LISTEN_HOST`). Defaults
|
||||
* to 0.0.0.0 so Docker port-forwarding works out of the box; 127.0.0.1
|
||||
* restricts it to loopback.
|
||||
*/
|
||||
export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") ||
|
||||
"0.0.0.0";
|
||||
/**
|
||||
* The server's public-facing URL (`GERBEUR_PUBLIC_URL`), without a trailing
|
||||
* slash — used for CORS, WebSocket origin checks, email links, and OG image
|
||||
* URLs. Behind a reverse proxy, set it to the externally-visible URL (e.g.
|
||||
* https://example.com). Defaults to http://localhost:PORT for local
|
||||
* development.
|
||||
*/
|
||||
export const PUBLIC_URL: string =
|
||||
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
|
||||
`http://localhost:${PORT}`;
|
||||
// In single-container deployments the API serves the frontend, so FRONTEND_URL
|
||||
// equals PUBLIC_URL. Override with GERBEUR_FRONTEND_URL when running the
|
||||
// frontend on a separate host/port (e.g. Vite dev server or a CDN origin).
|
||||
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
|
||||
PUBLIC_URL;
|
||||
// Overridable so the test suite can run against a throwaway database and
|
||||
// uploads directory (see api/test/setup.ts).
|
||||
export const DB_PATH = Deno.env.get("GERBEUR_DB_PATH") || "api/sql/gerbeur.db";
|
||||
/**
|
||||
* The frontend's URL (`GERBEUR_FRONTEND_URL`). In single-container deployments
|
||||
* the API serves the frontend, so it defaults to {@link PUBLIC_URL}; set it
|
||||
* when the frontend runs on a separate host or port (e.g. Vite dev server or a
|
||||
* CDN origin).
|
||||
*/
|
||||
export const FRONTEND_URL: string =
|
||||
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL;
|
||||
/**
|
||||
* The SQLite database file (`GERBEUR_DB_PATH`). Overridable so the test suite
|
||||
* can run against a throwaway database (see api/test/setup.ts).
|
||||
*/
|
||||
export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") ||
|
||||
"api/sql/gerbeur.db";
|
||||
|
||||
// Upload/files
|
||||
export const UPLOADS_DIR = Deno.env.get("GERBEUR_UPLOADS_DIR") || "api/uploads";
|
||||
/**
|
||||
* The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`), one
|
||||
* subdirectory per kind.
|
||||
*/
|
||||
export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") ||
|
||||
"api/uploads";
|
||||
/** Where file dumps are stored. */
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
/** Where avatars are stored. */
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
/** Where playlist cover images are stored. */
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
/** Where images pasted into chat, comments and descriptions are stored. */
|
||||
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
/** Where custom dump thumbnails are stored. */
|
||||
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
|
||||
|
||||
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||
/** The largest image upload accepted: 5 MB. */
|
||||
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
/** The image types accepted for avatars, covers, thumbnails and attachments. */
|
||||
export const ALLOWED_IMAGE_MIMES: Set<string> = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
export const DUMP_ALLOWED_MIME_PREFIXES = [
|
||||
/** The MIME type prefixes a file dump may have. */
|
||||
export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [
|
||||
"text/",
|
||||
"image/",
|
||||
"video/",
|
||||
"audio/",
|
||||
];
|
||||
export const DUMP_ALLOWED_MIME_TYPES = new Set([
|
||||
/** The exact MIME types a file dump may have, beyond the prefixes. */
|
||||
export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/zip",
|
||||
@@ -68,37 +115,56 @@ export const DUMP_ALLOWED_MIME_TYPES = new Set([
|
||||
]);
|
||||
|
||||
// Pagination
|
||||
/** The page size when a request names none. */
|
||||
export const PAGINATION_DEFAULT_LIMIT = 20;
|
||||
/** The largest page size a request may ask for. */
|
||||
export const PAGINATION_MAX_LIMIT = 100;
|
||||
|
||||
// Startup housekeeping
|
||||
/** How long an attachment nothing links to is kept before startup purges it. */
|
||||
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
|
||||
/** How long an unused invite is kept before startup purges it. */
|
||||
export const UNUSED_INVITES_RETENTION_DAYS = 7;
|
||||
|
||||
// SEO/OG
|
||||
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
||||
/** The site's name (`GERBEUR_SITE_NAME`), in page titles, OG tags and mail. */
|
||||
export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") ||
|
||||
"gerbeur";
|
||||
|
||||
// Emoji the favicon set + app header logo are generated/rendered from.
|
||||
// Applied at runtime (server startup + index.html injection), so changing it
|
||||
// only needs a restart — no rebuild.
|
||||
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
|
||||
/**
|
||||
* The emoji the favicon set and app header logo are generated from
|
||||
* (`GERBEUR_SITE_EMOJI`). Applied at runtime (server startup and index.html
|
||||
* injection), so changing it only needs a restart — no rebuild.
|
||||
*/
|
||||
export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
|
||||
|
||||
// Which Bandcamp playback path the frontend uses.
|
||||
// "embed" — the bandcamp.com iframe (cannot autoplay)
|
||||
// "native" — resolve the page's mp3 streams and play them in the app's own
|
||||
// player, which autoplays and supports albums as a queue
|
||||
// Anything unrecognised falls back to "embed", so a typo degrades safely.
|
||||
/**
|
||||
* A Bandcamp playback path:
|
||||
* - `"embed"` — the bandcamp.com iframe (cannot autoplay)
|
||||
* - `"native"` — resolve the page's mp3 streams and play them in the app's own
|
||||
* player, which autoplays and supports albums as a queue
|
||||
*/
|
||||
export type BandcampPlayer = "embed" | "native";
|
||||
/**
|
||||
* The Bandcamp playback path the frontend uses (`GERBEUR_BANDCAMP_PLAYER`).
|
||||
* Anything unrecognised falls back to `"embed"`, so a typo degrades safely.
|
||||
*/
|
||||
export const BANDCAMP_PLAYER: BandcampPlayer =
|
||||
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
|
||||
? "native"
|
||||
: "embed";
|
||||
|
||||
// Background color for generated icons and the manifest. Mirrors the
|
||||
// hard-coded theme-color in index.html.
|
||||
/**
|
||||
* The background color of generated icons and the manifest. Mirrors the
|
||||
* hard-coded theme-color in index.html.
|
||||
*/
|
||||
export const THEME_COLOR = "#111827";
|
||||
|
||||
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "";
|
||||
/**
|
||||
* The origins CORS and the WebSocket accept: {@link PUBLIC_URL},
|
||||
* {@link FRONTEND_URL} and the comma-separated `GERBEUR_ALLOWED_ORIGINS`.
|
||||
*/
|
||||
export const ALLOWED_ORIGINS: string[] = Array.from(
|
||||
new Set([
|
||||
PUBLIC_URL,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* The database handle. Importing this module opens the database at
|
||||
* {@link DB_PATH}, applies the pending migrations and runs the startup tasks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
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);
|
||||
/** The one connection every service queries through. */
|
||||
export const db: DatabaseSync = 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
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet;
|
||||
* run once before the server starts (`deno task start`).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { DB_PATH } from "../config.ts";
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Brings an existing database up to date with the migrations under
|
||||
* migrations/, recording each applied one in `schema_migrations`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
||||
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
||||
@@ -21,9 +28,12 @@ interface Migration {
|
||||
up: (db: DatabaseSync) => void;
|
||||
}
|
||||
|
||||
// Append-only — never edit or reorder a migration once it has shipped.
|
||||
// Each `up` must be idempotent: it also runs against fresh databases created
|
||||
// straight from schema.sql, where the change it makes already exists.
|
||||
/**
|
||||
* Every migration, in order. Append-only — never edit or reorder a migration
|
||||
* once it has shipped. Each `up` must be idempotent: it also runs against
|
||||
* fresh databases created straight from schema.sql, where the change it makes
|
||||
* already exists.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
||||
@@ -46,6 +56,13 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0016_performance_indexes", up: up0016PerformanceIndexes },
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies each migration `db` hasn't recorded yet, each in its own
|
||||
* transaction.
|
||||
*
|
||||
* @throws {Error} When a migration fails; that migration is rolled back and
|
||||
* the later ones aren't run.
|
||||
*/
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* Adds comment likes: the `comment_likes` table and the `comments.like_count`
|
||||
* counter.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with these columns/tables) or an existing one that predates them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with these columns/tables) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Adds `dumps.custom_thumbnail_mime`, for thumbnails uploaded by the poster.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with this column) or an existing one that predates it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with this column) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* Adds the `dump_backlinks` table, recording which dumps, comments and
|
||||
* descriptions link to a dump, and indexes `dumps.url`.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with these) or an existing one that predates them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with these) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
|
||||
source_type TEXT NOT NULL,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Adds `users.role` and makes every legacy `is_admin` user an admin.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with the `role` column) or an existing one that predates it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with the `role` column) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Drops the legacy is_admin column now that `role` is the single source of
|
||||
* truth. Idempotent: skips when the column is already gone (fresh databases
|
||||
* created from schema.sql never had it). Must run after 0004 has backfilled.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Drops the legacy is_admin column now that `role` is the single source of
|
||||
// truth. Idempotent: skips when the column is already gone (fresh databases
|
||||
// created from schema.sql never had it). Must run after 0004 has backfilled.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* Adds categories: the `categories` table and the `dump_categories` join
|
||||
* table.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with the categories tables) or an existing one that predates them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with the categories tables) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Adds the `password_reset_tokens` table.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with password_reset_tokens) or an existing one that predates the table.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with password_reset_tokens) or an existing one that predates the table.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Adds the `chat_messages` table.
|
||||
*
|
||||
* Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
* with this table) or an existing one that predates it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with this table) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* Adds an optional self-reference so a chat message can reply to another.
|
||||
* Kept as a plain column (no FK): when the replied-to message is later deleted
|
||||
* the id is retained but the join yields null, letting the UI show a subtle
|
||||
* "deleted message" reference instead of breaking the insert.
|
||||
*
|
||||
* Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
* the column already exists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Adds an optional self-reference so a chat message can reply to another.
|
||||
// Kept as a plain column (no FK): when the replied-to message is later deleted
|
||||
// the id is retained but the join yields null, letting the UI show a subtle
|
||||
// "deleted message" reference instead of breaking the insert.
|
||||
//
|
||||
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
// the column already exists.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
/**
|
||||
* Backfills the `start` parameter on stored YouTube embed URLs.
|
||||
*
|
||||
* `rich_content.embedUrl` is computed once, at dump creation, and previously
|
||||
* dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so
|
||||
* those videos always opened at 0:00 in the global player. The provider now
|
||||
* translates `t`/`start` into the `start` param the iframe player honours; this
|
||||
* migration applies the same translation to rows written before that fix.
|
||||
*
|
||||
* Purely local — it re-derives the param from the already-stored source URL and
|
||||
* makes no network calls. Self-contained (no imports from api/services) so it
|
||||
* stays reproducible even if the provider's parsing evolves later.
|
||||
*
|
||||
* Idempotent: rows whose embed URL already carries a `start` are skipped, so a
|
||||
* fresh database built from schema.sql is simply a no-op.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Backfills the `start` parameter on stored YouTube embed URLs.
|
||||
//
|
||||
// `rich_content.embedUrl` is computed once, at dump creation, and previously
|
||||
// dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so
|
||||
// those videos always opened at 0:00 in the global player. The provider now
|
||||
// translates `t`/`start` into the `start` param the iframe player honours; this
|
||||
// migration applies the same translation to rows written before that fix.
|
||||
//
|
||||
// Purely local — it re-derives the param from the already-stored source URL and
|
||||
// makes no network calls. Self-contained (no imports from api/services) so it
|
||||
// stays reproducible even if the provider's parsing evolves later.
|
||||
//
|
||||
// Idempotent: rows whose embed URL already carries a `start` are skipped, so a
|
||||
// fresh database built from schema.sql is simply a no-op.
|
||||
|
||||
/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
|
||||
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
|
||||
function extractStartSeconds(url: string): number | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
@@ -34,6 +38,7 @@ function extractStartSeconds(url: string): number | null {
|
||||
Number(hms[3] ?? 0);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, url, rich_content FROM dumps
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
/**
|
||||
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||
* `faviconUrl` field.
|
||||
*
|
||||
* The extraction cascade used to end at the page's icon and then at a guessed
|
||||
* `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
|
||||
* held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
|
||||
* box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
|
||||
* means "no artwork", which is what lets the frontend draw a placeholder.
|
||||
* This migration gives rows written before that change the same meaning.
|
||||
*
|
||||
* Purely local — it classifies the already-stored URL and makes no network
|
||||
* calls, so `accentColor` is deliberately not backfilled: the frontend derives
|
||||
* a stable hue from the hostname whenever one is missing, and
|
||||
* `refreshDumpMetadata` fetches the real color on demand.
|
||||
*
|
||||
* Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
|
||||
* database built from schema.sql is a no-op.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||
// `faviconUrl` field.
|
||||
//
|
||||
// The extraction cascade used to end at the page's icon and then at a guessed
|
||||
// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
|
||||
// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
|
||||
// box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
|
||||
// means "no artwork", which is what lets the frontend draw a placeholder.
|
||||
// This migration gives rows written before that change the same meaning.
|
||||
//
|
||||
// Purely local — it classifies the already-stored URL and makes no network
|
||||
// calls, so `accentColor` is deliberately not backfilled: the frontend derives
|
||||
// a stable hue from the hostname whenever one is missing, and
|
||||
// `refreshDumpMetadata` fetches the real color on demand.
|
||||
//
|
||||
// Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
|
||||
// database built from schema.sql is a no-op.
|
||||
|
||||
/**
|
||||
* Whether a stored thumbnail URL is really a site icon.
|
||||
* Checks whether a stored thumbnail URL is really a site icon.
|
||||
*
|
||||
* Deliberately loose. A false positive (a genuine cover image living under
|
||||
* `/assets/icons/`) renders contained on a tinted field instead of
|
||||
@@ -37,6 +41,7 @@ function isIconUrl(raw: string): boolean {
|
||||
/\.(ico|svg)$/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
/**
|
||||
* Repairs 0011, which was too eager about what counts as a site icon.
|
||||
*
|
||||
* 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image`
|
||||
* like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was
|
||||
* moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
|
||||
* the dump from an image card to a pull-quote in the journal mosaic. A blanket
|
||||
* `.svg` match had the same effect on real artwork.
|
||||
*
|
||||
* `isIconUrl` here looks at the *filename* rather than the whole path: an icon
|
||||
* is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
|
||||
* directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
|
||||
* catches `icon_SEARCH.png`, which 0011 missed.
|
||||
*
|
||||
* Applied in both directions, and deliberately narrow when reversing: a value
|
||||
* is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
|
||||
* this one, so rows written by a normal fetch aren't disturbed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Repairs 0011, which was too eager about what counts as a site icon.
|
||||
//
|
||||
// 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image`
|
||||
// like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was
|
||||
// moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
|
||||
// the dump from an image card to a pull-quote in the journal mosaic. A blanket
|
||||
// `.svg` match had the same effect on real artwork.
|
||||
//
|
||||
// `isIconUrl` here looks at the *filename* rather than the whole path: an icon
|
||||
// is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
|
||||
// directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
|
||||
// catches `icon_SEARCH.png`, which 0011 missed.
|
||||
//
|
||||
// Applied in both directions, and deliberately narrow when reversing: a value
|
||||
// is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
|
||||
// this one, so rows written by a normal fetch aren't disturbed.
|
||||
|
||||
/** 0011's rule, kept verbatim so its false positives can be identified. */
|
||||
/**
|
||||
* Applies 0011's rule, kept verbatim so its false positives can be
|
||||
* identified.
|
||||
*/
|
||||
function wasIconUrl0011(raw: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
@@ -29,7 +36,10 @@ function wasIconUrl0011(raw: string): boolean {
|
||||
/\.(ico|svg)$/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Whether a URL names a site icon, judged on its filename and directory. */
|
||||
/**
|
||||
* Checks whether a URL names a site icon, judged on its filename and
|
||||
* directory.
|
||||
*/
|
||||
function isIconUrl(raw: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
@@ -45,6 +55,7 @@ function isIconUrl(raw: string): boolean {
|
||||
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
/**
|
||||
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||
* check on the create form — and backfills it for every existing URL dump.
|
||||
*
|
||||
* Purely local: it re-derives the key from the URL already stored on each row
|
||||
* and makes no network calls.
|
||||
*
|
||||
* The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
|
||||
* as it stood when this migration shipped, not an import of it. A migration
|
||||
* runs exactly once per database, so importing the live version would mean two
|
||||
* databases migrating at different times end up with keys computed under
|
||||
* different rules. Improving the shared canonicalizer therefore calls for a new
|
||||
* backfill migration rather than an edit here.
|
||||
*
|
||||
* Idempotent: the column is only added when missing and only rows whose key is
|
||||
* still NULL are touched, so a fresh database built from schema.sql is a no-op.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||
// check on the create form — and backfills it for every existing URL dump.
|
||||
//
|
||||
// Purely local: it re-derives the key from the URL already stored on each row
|
||||
// and makes no network calls.
|
||||
//
|
||||
// The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
|
||||
// as it stood when this migration shipped, not an import of it. A migration
|
||||
// runs exactly once per database, so importing the live version would mean two
|
||||
// databases migrating at different times end up with keys computed under
|
||||
// different rules. Improving the shared canonicalizer therefore calls for a new
|
||||
// backfill migration rather than an edit here.
|
||||
//
|
||||
// Idempotent: the column is only added when missing and only rows whose key is
|
||||
// still NULL are touched, so a fresh database built from schema.sql is a no-op.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const TRACKING_PARAMS = new Set([
|
||||
"fbclid",
|
||||
@@ -94,6 +98,7 @@ function canonicalizeUrl(raw: string): string | null {
|
||||
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* Chat unread state lives on the server so it survives reloads and agrees
|
||||
* across tabs and devices: `users.chat_read_at` is the created_at of the newest
|
||||
* message the user has seen. Existing users start caught up (now) rather than
|
||||
* with the whole history unread; new users fall back to their own created_at.
|
||||
*
|
||||
* The history cursor now pages on (created_at, id) so two messages sharing a
|
||||
* millisecond can't be skipped at a page boundary; the index follows suit.
|
||||
*
|
||||
* Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
* the column and index already exist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Chat unread state lives on the server so it survives reloads and agrees
|
||||
// across tabs and devices: `users.chat_read_at` is the created_at of the newest
|
||||
// message the user has seen. Existing users start caught up (now) rather than
|
||||
// with the whole history unread; new users fall back to their own created_at.
|
||||
//
|
||||
// The history cursor now pages on (created_at, id) so two messages sharing a
|
||||
// millisecond can't be skipped at a page boundary; the index follows suit.
|
||||
//
|
||||
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
// the column and index already exist.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* Changing or resetting a password must end every session opened before it.
|
||||
* Tokens are stateless JWTs, so each user carries a cut-off instead:
|
||||
* `users.tokens_valid_after` (ISO), and a token issued before it is refused.
|
||||
* NULL — the state every existing user starts in — means no cut-off.
|
||||
*
|
||||
* Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
* the column already exists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Changing or resetting a password must end every session opened before it.
|
||||
// Tokens are stateless JWTs, so each user carries a cut-off instead:
|
||||
// `users.tokens_valid_after` (ISO), and a token issued before it is refused.
|
||||
// NULL — the state every existing user starts in — means no cut-off.
|
||||
//
|
||||
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
// the column already exists.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
/**
|
||||
* Indexes for the queries that ran as full scans:
|
||||
* - feeds page by created_at (newest first), a user's dumps and upvotes by
|
||||
* (user_id, created_at) — these replace the bare user_id indexes;
|
||||
* - dump and playlist pages are looked up by slug;
|
||||
* - the child side of every ON DELETE CASCADE / SET NULL foreign key, which
|
||||
* SQLite otherwise scans on each parent delete (a comment's replies, a user's
|
||||
* comments, follows, chat messages, invites, reset tokens), and the
|
||||
* followers of a user or playlist, read on every new dump;
|
||||
* - @mentions resolve usernames case-insensitively, by lower(username).
|
||||
*
|
||||
* Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
* these indexes already exist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Indexes for the queries that ran as full scans:
|
||||
// - feeds page by created_at (newest first), a user's dumps and upvotes by
|
||||
// (user_id, created_at) — these replace the bare user_id indexes;
|
||||
// - dump and playlist pages are looked up by slug;
|
||||
// - the child side of every ON DELETE CASCADE / SET NULL foreign key, which
|
||||
// SQLite otherwise scans on each parent delete (a comment's replies, a user's
|
||||
// comments, follows, chat messages, invites, reset tokens), and the
|
||||
// followers of a user or playlist, read on every new dump;
|
||||
// - @mentions resolve usernames case-insensitively, by lower(username).
|
||||
//
|
||||
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||
// these indexes already exist.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_dumps_user;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Housekeeping run when the database is opened.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
@@ -7,8 +13,9 @@ import {
|
||||
} 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.
|
||||
* Runs once when the database is opened, after the migrations: drops what has
|
||||
* expired (reset tokens, unused invites, orphaned attachments), and seeds the
|
||||
* default admin (admin/admin) on an empty instance.
|
||||
*/
|
||||
export async function runStartupTasks(db: DatabaseSync): Promise<void> {
|
||||
// Purge expired/used password reset tokens
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
/**
|
||||
* The exception that carries an HTTP error out of any layer below a route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { APIErrorCode } from "../../shared/api.ts";
|
||||
|
||||
/** Thrown anywhere below a route; errorMiddleware turns it into a response. */
|
||||
/**
|
||||
* An error meant for the client, thrown anywhere below a route; the error
|
||||
* middleware turns it into an `APIFailure` response.
|
||||
*/
|
||||
export class APIException extends Error {
|
||||
/** The error code the response carries. */
|
||||
readonly code: APIErrorCode;
|
||||
/** The HTTP status the response is sent with. */
|
||||
readonly status: number;
|
||||
|
||||
/** Creates an exception answering `status` with `code` and `message`. */
|
||||
constructor(code: APIErrorCode, status: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Authenticates the tokens a request carries: session tokens in the
|
||||
* `Authorization` header and media tokens in a media URL's query string.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
@@ -58,7 +65,8 @@ export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the userId from an optional Bearer token.
|
||||
* Returns the user id of an optional Bearer token, or null for an anonymous
|
||||
* request.
|
||||
*
|
||||
* Distinguishes *missing* credentials from *invalid* ones:
|
||||
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
||||
@@ -67,6 +75,8 @@ export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
* throws 401, so a stale client session surfaces as an auth error it can react
|
||||
* to, instead of being silently downgraded to anonymous (which yields a
|
||||
* misleading 404 on private resources).
|
||||
*
|
||||
* @throws {APIException} 401 when a token is present but fails verification.
|
||||
*/
|
||||
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Canonical form of a URL, used *only* to recognise that two links point at the
|
||||
* The canonical form of a URL, used *only* to recognise that two links point at the
|
||||
* same thing — the duplicate check the create form runs while it fetches a
|
||||
* preview. It is a lookup key, never something we display or fetch: `dumps.url`
|
||||
* keeps the exact string the poster submitted.
|
||||
@@ -13,6 +13,8 @@
|
||||
* Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this
|
||||
* logic to backfill existing rows; changing the rules here therefore needs a
|
||||
* new backfill migration, or old rows keep keys computed under the old rules.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
const TRACKING_PARAMS = new Set([
|
||||
@@ -40,7 +42,7 @@ function isTrackingParam(key: string): boolean {
|
||||
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
|
||||
}
|
||||
|
||||
// Hosts already stripped of a leading "www.".
|
||||
/** YouTube's hosts, already stripped of a leading "www.". */
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
"youtube.com",
|
||||
"m.youtube.com",
|
||||
@@ -49,7 +51,7 @@ const YOUTUBE_HOSTS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* The video a YouTube URL points at, in any of the shapes people paste
|
||||
* Returns the video a YouTube URL points at, in any of the shapes people paste
|
||||
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`).
|
||||
* Timestamps are deliberately ignored: the same video linked at 2:30 is still
|
||||
* the same video for duplicate purposes.
|
||||
@@ -68,6 +70,10 @@ function youtubeVideoId(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical form of `raw` (see the module doc), or null when it
|
||||
* isn't an http(s) URL.
|
||||
*/
|
||||
export function canonicalizeUrl(raw: string): string | null {
|
||||
let u: URL;
|
||||
try {
|
||||
|
||||
38
api/lib/index-html.ts
Normal file
38
api/lib/index-html.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The built frontend's `index.html`, with its runtime placeholders filled: the
|
||||
* site name, emoji, icon version and Bandcamp player mode.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
/** The cache-busting version of the icon URLs. */
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
|
||||
/**
|
||||
* `index.html` with its placeholders filled, by root. Kept until the file
|
||||
* changes (a rebuild under a running server): a stat per navigation instead of
|
||||
* a read and four replaceAll.
|
||||
*/
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* Returns `${root}/index.html` with its placeholders filled.
|
||||
*
|
||||
* @throws {Error} When the file can't be read.
|
||||
*/
|
||||
export async function loadIndexHtml(root: string): Promise<string> {
|
||||
const path = `${root}/index.html`;
|
||||
const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
const cached = indexHtml.get(root);
|
||||
if (cached && cached.mtime === mtime) return cached.html;
|
||||
const html = (await Deno.readTextFile(path))
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
indexHtml.set(root, { mtime, html });
|
||||
return html;
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Signed tokens and password hashes: session tokens, media tokens, password
|
||||
* reset tokens, and scrypt hashing.
|
||||
*
|
||||
* Importing this module throws when `GERBEUR_JWT_SECRET` is unset.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { jwtVerify, SignJWT } from "@panva/jose";
|
||||
@@ -19,6 +28,7 @@ const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
// ── Password reset tokens ─────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a single-use password reset token for `userId`, valid for an hour. */
|
||||
export async function createPasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
@@ -29,6 +39,10 @@ export async function createPasswordResetToken(
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password reset token and returns its payload, or null when it is
|
||||
* invalid, expired or not a reset token.
|
||||
*/
|
||||
export async function verifyPasswordResetToken(
|
||||
token: string,
|
||||
): Promise<PasswordResetPayload | null> {
|
||||
@@ -43,6 +57,7 @@ export async function verifyPasswordResetToken(
|
||||
|
||||
// ── Auth tokens ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a session token carrying `payload`, valid for 7 days. */
|
||||
export async function createJWT(
|
||||
payload: Omit<AuthPayload, "exp">,
|
||||
): Promise<string> {
|
||||
@@ -56,10 +71,14 @@ export async function createJWT(
|
||||
|
||||
// ── Media tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Media elements can't send an Authorization header, so a private dump's file
|
||||
// and thumbnail URLs carry a token in the query string, where it lands in
|
||||
// access logs and browser history. That token is this one: it only reads media,
|
||||
// so a leaked URL exposes the owner's private files, not their account.
|
||||
/**
|
||||
* Signs a media token for `userId`, valid for 7 days.
|
||||
*
|
||||
* Media elements can't send an Authorization header, so a private dump's file
|
||||
* and thumbnail URLs carry a token in the query string, where it lands in
|
||||
* access logs and browser history. That token is this one: it only reads media,
|
||||
* so a leaked URL exposes the owner's private files, not their account.
|
||||
*/
|
||||
export async function createMediaToken(userId: string): Promise<string> {
|
||||
return await new SignJWT({ purpose: "media", userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
@@ -68,6 +87,10 @@ export async function createMediaToken(userId: string): Promise<string> {
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a media token and returns its user id and issue time, or null when
|
||||
* it is invalid, expired or not a media token.
|
||||
*/
|
||||
export async function verifyMediaToken(
|
||||
token: string,
|
||||
): Promise<{ userId: string; iat?: number } | null> {
|
||||
@@ -82,6 +105,10 @@ export async function verifyMediaToken(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a session token's signature and expiry and returns its payload, or
|
||||
* null. Checking it against the live user is `authenticate`'s job (lib/auth.ts).
|
||||
*/
|
||||
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||
@@ -97,6 +124,7 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
|
||||
@@ -108,6 +136,7 @@ export function hashPassword(password: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Checks `password` against a hash made by {@link hashPassword}. */
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
storedHash: string,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* Page-based pagination: reading `page` and `limit` from a query string, and
|
||||
* cutting fetched rows into a page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
|
||||
|
||||
/**
|
||||
* Parses page/limit query parameters with sensible defaults and bounds.
|
||||
* page: clamped to [1, ∞)
|
||||
* limit: clamped to [1, 100], defaults to 20
|
||||
* Reads the `page` and `limit` query parameters. `page` is at least 1 and
|
||||
* defaults to 1; `limit` is clamped to [1, {@link PAGINATION_MAX_LIMIT}] and
|
||||
* defaults to `defaultLimit`.
|
||||
*/
|
||||
export function parsePagination(
|
||||
params: URLSearchParams,
|
||||
@@ -25,7 +32,9 @@ export function parsePagination(
|
||||
|
||||
/** One page of a list, and whether another follows. */
|
||||
export interface Page<T> {
|
||||
/** The page's items, in order. */
|
||||
items: T[];
|
||||
/** Whether another page follows this one. */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/**
|
||||
* Permission checks against an authenticated caller.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { hasPermission, type Permission } from "../../shared/permissions.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
|
||||
/** Checks whether the caller's role grants `permission`. */
|
||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||
return hasPermission(payload.role, permission);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* Which of a playlist's dumps a given viewer may see.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/** A playlist's dump, with what deciding who may see it takes. */
|
||||
export interface PlaylistMember {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** Whether the dump is private. */
|
||||
isPrivate: boolean;
|
||||
/** The id of the dump's poster. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `viewerId` sees this member when the playlist is shown to them: a
|
||||
* Checks whether `viewerId` sees this member when the playlist is shown to them: a
|
||||
* public dump, or a private one of the owner's own, to the owner. Everyone
|
||||
* else — moderators too — doesn't see private dumps, and neither does the
|
||||
* owner see someone else's dump once it has gone private.
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// Token buckets keyed by the caller's choice (e.g. `${kind}:${userId}`) — per
|
||||
// user rather than per socket or request, so more tabs or parallel requests
|
||||
// don't buy more throughput.
|
||||
/**
|
||||
* In-memory rate limiting with token buckets, keyed by the caller's choice
|
||||
* (e.g. `${kind}:${userId}`) — per user rather than per socket or request, so
|
||||
* more tabs or parallel requests don't buy more throughput.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
at: number;
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** A token bucket's shape. */
|
||||
export interface RateLimit {
|
||||
/** Burst size. */
|
||||
capacity: number;
|
||||
@@ -14,7 +20,7 @@ export interface RateLimit {
|
||||
refill: number;
|
||||
}
|
||||
|
||||
/** Takes one token for `key`; false when the bucket is empty. */
|
||||
/** Takes one token from `key`'s bucket, or returns false when it is empty. */
|
||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Top-level path segments that already have meaning in the SPA router
|
||||
// (see src/App.tsx) plus a few reserved for infrastructure. Category slugs
|
||||
// live at the URL root (e.g. /music), so they must never collide with these.
|
||||
/**
|
||||
* Which slugs a category may take. Category slugs live at the URL root (e.g.
|
||||
* /music), so they must never collide with the frontend's own routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top-level path segments that already have meaning in the SPA router (see
|
||||
* src/App.tsx), plus a few reserved for infrastructure.
|
||||
*/
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
"~",
|
||||
"dumps",
|
||||
@@ -16,18 +24,23 @@ export const RESERVED_SLUGS: readonly string[] = [
|
||||
"assets",
|
||||
];
|
||||
|
||||
// Lowercase alphanumerics, hyphen-separated, no leading/trailing/double hyphen.
|
||||
/**
|
||||
* A well-formed category slug: lowercase alphanumerics,
|
||||
* hyphen-separated, no leading, trailing or double hyphen.
|
||||
*/
|
||||
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Checks whether `slug` is well-formed (see {@link CATEGORY_SLUG_RE}). */
|
||||
export function isValidSlugFormat(slug: string): boolean {
|
||||
return CATEGORY_SLUG_RE.test(slug);
|
||||
}
|
||||
|
||||
/** Checks whether `slug` is one of the {@link RESERVED_SLUGS}. */
|
||||
export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.includes(slug);
|
||||
}
|
||||
|
||||
/** A slug is available when it is well-formed and not reserved. */
|
||||
/** Checks whether `slug` is well-formed and not reserved. */
|
||||
export function isSlugAvailable(slug: string): boolean {
|
||||
return isValidSlugFormat(slug) && !isReservedSlug(slug);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Recognises links to this site's own pages, which previews resolve from the
|
||||
* database instead of fetching.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
|
||||
|
||||
const SELF_HOSTS = new Set(
|
||||
@@ -13,9 +20,9 @@ const SELF_HOSTS = new Set(
|
||||
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
|
||||
|
||||
/**
|
||||
* A link to one of this site's own dump, user or playlist pages. Matched on the
|
||||
* host as well as the path: `/users/bob` on some other site is someone else's
|
||||
* page, not ours.
|
||||
* Checks whether `url` links to one of this site's own dump, user or playlist
|
||||
* pages. Matched on the host as well as the path: `/users/bob` on some other
|
||||
* site is someone else's page, not ours.
|
||||
*/
|
||||
export function isSelfUrl(url: URL): boolean {
|
||||
return SELF_HOSTS.has(url.host.toLowerCase()) &&
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Serving files from disk with ETags and byte ranges, streamed a chunk at a
|
||||
* time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
@@ -52,7 +59,7 @@ function fileStream(
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an If-None-Match header names `etag` (weak comparison). */
|
||||
/** Checks whether an If-None-Match header names `etag` (weak comparison). */
|
||||
function matchesEtag(header: string | null, etag: string): boolean {
|
||||
if (!header) return false;
|
||||
if (header.trim() === "*") return true;
|
||||
@@ -60,8 +67,11 @@ function matchesEtag(header: string | null, etag: string): boolean {
|
||||
return header.split(",").some((tag) => strip(tag) === strip(etag));
|
||||
}
|
||||
|
||||
/** How {@link serveFile} answers. */
|
||||
export interface ServeFileOptions {
|
||||
/** The `Content-Type` sent. */
|
||||
mime: string;
|
||||
/** The `Cache-Control` sent. */
|
||||
cacheControl: string;
|
||||
/** Answer a single `Range: bytes=…` with a 206 (media files). */
|
||||
ranges?: boolean;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Generates the full favicon set + web manifest from a single emoji.
|
||||
//
|
||||
// Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without
|
||||
// any system emoji font (which matters in the minimal Alpine runtime image).
|
||||
// @resvg/resvg-wasm does the rasterization; the .ico is assembled by hand.
|
||||
//
|
||||
// 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.
|
||||
/**
|
||||
* Generates the full favicon set and web manifest from a single emoji.
|
||||
*
|
||||
* Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without
|
||||
* any system emoji font (which matters in the minimal Alpine runtime image).
|
||||
* `@resvg/resvg-wasm` does the rasterization; the .ico is assembled by hand.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
||||
import { fetchTwemojiSvg } from "../remote/twemoji.ts";
|
||||
|
||||
@@ -14,9 +19,9 @@ const APPLE_SIZE = 180;
|
||||
const ICO_SIZES = [16, 32, 48];
|
||||
|
||||
/**
|
||||
* Mirror Twemoji's filename convention: strip variation selectors (FE0F) unless
|
||||
* the emoji is a ZWJ sequence, then join codepoints with "-". Also doubles as a
|
||||
* stable cache-busting version string for the icon URLs.
|
||||
* Returns the name Twemoji files `emoji` under: its codepoints joined with
|
||||
* "-", variation selectors (FE0F) stripped unless the emoji is a ZWJ sequence.
|
||||
* Also doubles as a stable cache-busting version string for the icon URLs.
|
||||
*/
|
||||
export function emojiToCodepoint(emoji: string): string {
|
||||
const cleaned = emoji.includes("") ? emoji : emoji.replace(/️/g, "");
|
||||
@@ -42,7 +47,7 @@ function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
// Pack PNG payloads into a (PNG-compressed) .ico container.
|
||||
/** Packs PNG payloads into a (PNG-compressed) .ico container. */
|
||||
function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
|
||||
const HEADER = 6;
|
||||
const ENTRY = 16;
|
||||
@@ -94,6 +99,7 @@ function buildManifest(siteName: string, bgColor: string): string {
|
||||
return JSON.stringify(manifest, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/** What {@link generateSiteIcons} renders, and where. */
|
||||
export interface GenerateSiteIconsOptions {
|
||||
/** Emoji to render the icons from. */
|
||||
emoji: string;
|
||||
@@ -101,14 +107,18 @@ export interface GenerateSiteIconsOptions {
|
||||
outDir: string;
|
||||
/** Site name written into the manifest. */
|
||||
siteName: string;
|
||||
/** Background color for the Apple touch icon and manifest (iOS ignores transparency). */
|
||||
/**
|
||||
* Background color for the Apple touch icon and manifest (iOS ignores
|
||||
* transparency).
|
||||
*/
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16–512),
|
||||
* Generates `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16–512),
|
||||
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`.
|
||||
* Throws if the emoji glyph can't be fetched.
|
||||
*
|
||||
* @throws {Error} When the emoji's glyph can't be fetched.
|
||||
*/
|
||||
export async function generateSiteIcons(
|
||||
opts: GenerateSiteIconsOptions,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
/**
|
||||
* URL slugs for records that have a title.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A UUID, which a route parameter may carry instead of a slug. */
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Turns `title` into a URL-safe slug: lowercase ASCII, dash-separated, at most
|
||||
* 60 characters, `untitled` when nothing is left.
|
||||
*/
|
||||
export function slugify(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
@@ -12,7 +23,10 @@ export function slugify(title: string): string {
|
||||
return slug || "untitled";
|
||||
}
|
||||
|
||||
/** Stable slug tied to the record's id — unique by construction. */
|
||||
/**
|
||||
* Returns the slug of a record: its title's slug followed by the start of its
|
||||
* `id`, so it stays unique by construction.
|
||||
*/
|
||||
export function makeSlug(title: string, id: string): string {
|
||||
return `${slugify(title)}-${id.substring(0, 8)}`;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
/**
|
||||
* Serving the built frontend: static files, and `index.html` with its
|
||||
* placeholders filled for every client-side route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next, send } from "@oak/oak";
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
|
||||
// index.html with its placeholders filled, by root. Kept until the file
|
||||
// changes (a rebuild under a running server): a stat per navigation instead of
|
||||
// a read and four replaceAll.
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
async function loadIndexHtml(root: string): Promise<string> {
|
||||
const path = `${root}/index.html`;
|
||||
const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
const cached = indexHtml.get(root);
|
||||
if (cached && cached.mtime === mtime) return cached.html;
|
||||
const html = (await Deno.readTextFile(path))
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
indexHtml.set(root, { mtime, html });
|
||||
return html;
|
||||
}
|
||||
import { loadIndexHtml } from "./index-html.ts";
|
||||
|
||||
/** Answers with the filled `index.html` of `root`, always revalidated. */
|
||||
async function serveIndexHtml(
|
||||
context: Context<Record<string, object>>,
|
||||
root: string,
|
||||
@@ -34,12 +20,22 @@ async function serveIndexHtml(
|
||||
context.response.body = html;
|
||||
}
|
||||
|
||||
// Build output whose URL changes whenever its content does: Vite's hashed
|
||||
// chunks, and the emoji data under its package version (see vite.config.ts).
|
||||
/**
|
||||
* Build output whose URL changes whenever its content does: Vite's hashed
|
||||
* chunks, and the emoji data under its package version (see vite.config.ts).
|
||||
*/
|
||||
const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"];
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function routeStaticFilesFrom(staticPaths: string[]) {
|
||||
/**
|
||||
* Returns a middleware serving static files from the first of `staticPaths`
|
||||
* that has them, and `index.html` from the first for any other path so
|
||||
* client-side routes load on direct navigation. Hashed build output is cached
|
||||
* for a year, and a missing chunk is a 404.
|
||||
*/
|
||||
export function routeStaticFilesFrom(
|
||||
staticPaths: string[],
|
||||
): (context: Context<Record<string, object>>, next: Next) => Promise<void> {
|
||||
return async (context: Context<Record<string, object>>, next: Next) => {
|
||||
const pathname = context.request.url.pathname;
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Checking uploaded images and serving uploaded files back.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
@@ -21,7 +27,10 @@ export {
|
||||
UPLOADS_DIR,
|
||||
};
|
||||
|
||||
/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */
|
||||
/**
|
||||
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
||||
* bytes, ignoring the type the browser declared; null for anything else.
|
||||
*/
|
||||
export function detectImageMime(data: Uint8Array): string | null {
|
||||
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
||||
return "image/jpeg";
|
||||
@@ -42,7 +51,12 @@ export function detectImageMime(data: Uint8Array): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
|
||||
/**
|
||||
* Checks an uploaded image's size and content, and returns its MIME type.
|
||||
*
|
||||
* @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
|
||||
* isn't a JPEG, PNG, GIF or WebP image.
|
||||
*/
|
||||
export function validateImageUpload(data: Uint8Array): string {
|
||||
if (data.length > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
@@ -62,6 +76,11 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
return mime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds
|
||||
* (privately when `isPrivate`). Answers 404 and resolves false when the file
|
||||
* is missing.
|
||||
*/
|
||||
export async function serveUploadedFile(
|
||||
ctx: Context,
|
||||
filePath: string,
|
||||
|
||||
11
api/main.ts
11
api/main.ts
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* The server's entry point: builds the Oak application — error and OG
|
||||
* middleware, CORS, every router, then the static frontend — and listens on
|
||||
* {@link LISTEN_HOST}:{@link PORT} when run directly.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Application } from "@oak/oak";
|
||||
import { oakCors } from "@tajpouria/cors";
|
||||
|
||||
@@ -25,7 +33,8 @@ import { errorMiddleware } from "./middleware/error.ts";
|
||||
import { ogMiddleware } from "./middleware/og.ts";
|
||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||
|
||||
const app = new Application();
|
||||
/** The application, exported so the tests can drive it without listening. */
|
||||
const app: Application = new Application();
|
||||
|
||||
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Middleware gating routes on authentication, the admin role, or a
|
||||
* permission.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
type Next,
|
||||
type RouteParams,
|
||||
@@ -11,18 +18,28 @@ import type { AuthPayload } from "../model/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import type { Permission } from "../../shared/permissions.ts";
|
||||
|
||||
/** The request state once {@link authMiddleware} has run. */
|
||||
export interface AuthState extends State {
|
||||
/** The authenticated caller, with their live role. */
|
||||
user: AuthPayload;
|
||||
}
|
||||
|
||||
// A router context whose state is guaranteed to carry the authenticated user
|
||||
// (populated by authMiddleware). Generic over the route path so `ctx.params`
|
||||
// stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
/**
|
||||
* A router context whose state is guaranteed to carry the authenticated user
|
||||
* (populated by {@link authMiddleware}). Generic over the route path so
|
||||
* `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
*/
|
||||
export type AuthContext<
|
||||
R extends string = string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
> = RouterContext<R, P, AuthState>;
|
||||
|
||||
/**
|
||||
* Requires a valid session token in the `Authorization` header and puts its
|
||||
* caller in `ctx.state.user`.
|
||||
*
|
||||
* @throws {APIException} 401 when the token is missing or invalid.
|
||||
*/
|
||||
export async function authMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
@@ -51,6 +68,11 @@ export async function authMiddleware<R extends string>(
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the caller to be an admin. Must run after {@link authMiddleware}.
|
||||
*
|
||||
* @throws {APIException} 403 when the caller isn't an admin.
|
||||
*/
|
||||
export async function adminOnlyMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
@@ -66,9 +88,15 @@ export async function adminOnlyMiddleware<R extends string>(
|
||||
await next();
|
||||
}
|
||||
|
||||
// Gates a route on a named permission. Must run after authMiddleware. Generic
|
||||
// over the route path so it composes with path-typed route handlers.
|
||||
export function requirePermission(permission: Permission) {
|
||||
/**
|
||||
* Returns a middleware requiring the caller to hold `permission`, which throws
|
||||
* an {@link APIException} (403) when they don't. Must run after
|
||||
* {@link authMiddleware}. Generic over the route path so it composes with
|
||||
* path-typed route handlers.
|
||||
*/
|
||||
export function requirePermission(
|
||||
permission: Permission,
|
||||
): <R extends string>(ctx: AuthContext<R>, next: Next) => Promise<void> {
|
||||
return async <R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/**
|
||||
* Turns errors thrown while handling a request into `APIFailure` responses.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next } from "@oak/oak";
|
||||
|
||||
import { APIErrorCode, APIFailure } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
|
||||
export async function errorMiddleware(ctx: Context, next: Next) {
|
||||
/**
|
||||
* Answers an {@link APIException} with its status and code, and anything else
|
||||
* with a 500 `SERVER_ERROR`, logging both.
|
||||
*/
|
||||
export async function errorMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* Open Graph and Twitter card tags for shared links: a dump, user or public
|
||||
* playlist page is answered with `index.html` carrying that page's title,
|
||||
* description and image, so link unfurlers that don't run the app see them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../services/user-service.ts";
|
||||
import { getPlaylistById } from "../services/playlist-service.ts";
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import { emojiToCodepoint } from "../lib/site-icons.ts";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
import { OG_SITE_NAME } from "../config.ts";
|
||||
import { loadIndexHtml } from "../lib/index-html.ts";
|
||||
|
||||
interface OGMeta {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
/** True only for real artwork — a square site icon must not claim a wide card. */
|
||||
/**
|
||||
* True only for real artwork — a square site icon must not claim a wide
|
||||
* card.
|
||||
*/
|
||||
imageIsWide?: boolean;
|
||||
url: string;
|
||||
}
|
||||
@@ -58,34 +67,34 @@ function inject(html: string, meta: OGMeta): string {
|
||||
.replace("</head>", ` ${buildTags(meta)}\n </head>`);
|
||||
}
|
||||
|
||||
let cachedHtml: string | null | undefined; // undefined = not yet loaded, null = not found
|
||||
|
||||
async function loadIndexHtml(): Promise<string | null> {
|
||||
if (cachedHtml !== undefined) return cachedHtml;
|
||||
for (
|
||||
const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`]
|
||||
) {
|
||||
/**
|
||||
* Returns the filled `index.html` of the build, or of the working directory
|
||||
* when there is no build; null when there is neither.
|
||||
*/
|
||||
async function findIndexHtml(): Promise<string | null> {
|
||||
for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) {
|
||||
try {
|
||||
cachedHtml = (await Deno.readTextFile(path))
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
return cachedHtml;
|
||||
return await loadIndexHtml(root);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cachedHtml = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
// Allow an optional `/~/<tab>` suffix so linkable profile tabs keep OG meta.
|
||||
/** A profile, with an optional `/~/<tab>` suffix so its tabs keep OG meta. */
|
||||
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
|
||||
export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
/**
|
||||
* Serves `index.html` with OG tags for a dump, user or public playlist page
|
||||
* that exists, and passes every other request on.
|
||||
*/
|
||||
export async function ogMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<unknown> {
|
||||
const { pathname } = ctx.request.url;
|
||||
|
||||
if (pathname.startsWith("/api/") || pathname.includes(".")) {
|
||||
@@ -153,7 +162,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
|
||||
if (!meta) return await next();
|
||||
|
||||
const html = await loadIndexHtml();
|
||||
const html = await findIndexHtml();
|
||||
if (!html) return await next();
|
||||
|
||||
ctx.response.headers.set("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* The payloads of the tokens the server signs, and their guards.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isRole, type Role } from "../../shared/user.ts";
|
||||
|
||||
/** What a session token carries. */
|
||||
export interface AuthPayload {
|
||||
/** The user's id. */
|
||||
userId: string;
|
||||
/** The user's name when the token was issued. */
|
||||
username: string;
|
||||
/**
|
||||
* The user's role; the live one replaces it once the token is authenticated.
|
||||
*/
|
||||
role: Role;
|
||||
/** When the token expires, in seconds since the epoch. */
|
||||
exp: number;
|
||||
/** When the token was issued, in seconds since the epoch. */
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped
|
||||
* token's payload.
|
||||
*/
|
||||
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -19,12 +37,17 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
"exp" in obj && typeof obj.exp === "number";
|
||||
}
|
||||
|
||||
/** What a password reset token carries. */
|
||||
export interface PasswordResetPayload {
|
||||
/** The token's purpose, which makes it a reset token. */
|
||||
purpose: "password-reset";
|
||||
/** The id of the user whose password it resets. */
|
||||
userId: string;
|
||||
/** When the token expires, in seconds since the epoch. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetPayload}. */
|
||||
export function isPasswordResetPayload(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetPayload {
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
/**
|
||||
* The `categories` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Category}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
|
||||
/** A row of the `categories` table. */
|
||||
export interface CategoryRow {
|
||||
/** The category's id. */
|
||||
id: string;
|
||||
/** The category's URL slug, at the site root. */
|
||||
slug: string;
|
||||
/** The category's display name. */
|
||||
name: string;
|
||||
/** The category's place in the list, ascending. */
|
||||
position: number;
|
||||
/** When the category was created. */
|
||||
created_at: string;
|
||||
/** When the category was last changed, if ever. */
|
||||
updated_at: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link CategoryRow}. */
|
||||
export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -22,6 +38,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Category}. */
|
||||
export function categoryRowToApi(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
/**
|
||||
* The `chat_messages` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link ChatMessage}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
|
||||
/**
|
||||
* A row of the `chat_messages` table, joined with its author and, when it is
|
||||
* a reply, the replied-to message and its author.
|
||||
*/
|
||||
export interface ChatMessageRow {
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** The author's id. */
|
||||
user_id: string;
|
||||
/** The message, in Markdown. */
|
||||
body: string;
|
||||
/** When the message was sent. */
|
||||
created_at: string;
|
||||
/** When the message was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The author's name (joined). */
|
||||
author_username: string;
|
||||
/** The author's avatar type, null without one (joined). */
|
||||
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).
|
||||
/**
|
||||
* The id of the message this replies to, kept even once that message is
|
||||
* deleted.
|
||||
*/
|
||||
reply_to_id: string | null;
|
||||
/**
|
||||
* The replied-to message's author (joined); null when there's no reply or it
|
||||
* was since deleted.
|
||||
*/
|
||||
reply_to_author: string | null;
|
||||
/**
|
||||
* The start of the replied-to message (joined); null when there's no reply or
|
||||
* it was since deleted.
|
||||
*/
|
||||
reply_to_body: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link ChatMessageRow}. */
|
||||
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -38,6 +67,7 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link ChatMessage}. */
|
||||
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
/**
|
||||
* The `comments` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Comment}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
|
||||
/** A row of the `comments` table, joined with its author. */
|
||||
export interface CommentRow {
|
||||
/** The comment's id. */
|
||||
id: string;
|
||||
/** The id of the dump it is posted under. */
|
||||
dump_id: string;
|
||||
/** The author's id. */
|
||||
user_id: string;
|
||||
/** The id of the comment it replies to; null at the top level. */
|
||||
parent_id: string | null;
|
||||
/** The comment, in Markdown. */
|
||||
body: string;
|
||||
/** When the comment was posted. */
|
||||
created_at: string;
|
||||
/** When the comment was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/**
|
||||
* 1 once deleted: the body is emptied but the row stays, so its replies
|
||||
* keep their place.
|
||||
*/
|
||||
deleted: number;
|
||||
/** How many users like the comment. */
|
||||
like_count: number;
|
||||
/** The author's name (joined). */
|
||||
author_username: string;
|
||||
/** The author's avatar type, null without one (joined). */
|
||||
author_avatar_mime: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link CommentRow}. */
|
||||
export function isCommentRow(
|
||||
obj: unknown,
|
||||
): obj is CommentRow {
|
||||
@@ -37,6 +61,7 @@ export function isCommentRow(
|
||||
obj.author_avatar_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Comment}. */
|
||||
export function commentRowToApi(row: CommentRow): Comment {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,41 +1,74 @@
|
||||
/**
|
||||
* The `dumps` table: its row type, its guard, the column lists services select
|
||||
* it with, and its mappers to and from the API's {@link Dump}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Dump, RichContent } from "../../shared/dump.ts";
|
||||
|
||||
/** A row of the `dumps` table, with its live comment count. */
|
||||
export interface DumpRow {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** `url` for a link, `file` for an upload. */
|
||||
kind: string;
|
||||
/** The dump's title. */
|
||||
title: string;
|
||||
/** The poster's description, in Markdown. */
|
||||
comment: string | null;
|
||||
/** The poster's id. */
|
||||
user_id: string;
|
||||
/** When the dump was posted. */
|
||||
created_at: string;
|
||||
/** When the dump was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The dump's URL slug. */
|
||||
slug: string | null;
|
||||
/** The link exactly as submitted, for a URL dump. */
|
||||
url: string | null;
|
||||
/** The link's preview, as {@link RichContent} JSON. */
|
||||
rich_content: string | null;
|
||||
/** The uploaded file's name, for a file dump. */
|
||||
file_name: string | null;
|
||||
/** The uploaded file's type, for a file dump. */
|
||||
file_mime: string | null;
|
||||
/** The uploaded file's size in bytes, for a file dump. */
|
||||
file_size: number | null;
|
||||
/** How many users upvoted the dump. */
|
||||
vote_count: number;
|
||||
/** How many comments it has, not counting deleted ones (computed). */
|
||||
comment_count: number;
|
||||
/** 1 when only the poster sees the dump. */
|
||||
is_private: number;
|
||||
/** The uploaded thumbnail's type, null without one. */
|
||||
custom_thumbnail_mime: string | null;
|
||||
[key: string]: SQLOutputValue; // Index signature
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow.
|
||||
// Services import these instead of repeating the column list themselves.
|
||||
/**
|
||||
* The stored columns of {@link DumpRow} — keep in sync with it and
|
||||
* {@link isDumpRow}. Services select with 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";
|
||||
|
||||
/** {@link DUMP_COLUMNS}, for a query aliasing `dumps` as `d`. */
|
||||
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},
|
||||
/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */
|
||||
export const DUMP_SELECT_COLUMNS: string = `${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},
|
||||
/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */
|
||||
export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
|
||||
|
||||
/** Checks whether `obj` is a {@link DumpRow}. */
|
||||
export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -66,6 +99,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
obj.custom_thumbnail_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Dump}. */
|
||||
export function dumpRowToApi(row: DumpRow): Dump {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -90,6 +124,7 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link Dump} back to a row. */
|
||||
export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
return {
|
||||
id: dump.id,
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
/**
|
||||
* The `follows` table: its row type and its guard.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* A row of the `follows` table: one user following either another user or a
|
||||
* playlist.
|
||||
*/
|
||||
export interface FollowRow {
|
||||
/** The follow's id. */
|
||||
id: string;
|
||||
/** The id of the user following. */
|
||||
follower_id: string;
|
||||
/** The id of the user followed, when it is a user. */
|
||||
followed_user_id: string | null;
|
||||
/** The id of the playlist followed, when it is a playlist. */
|
||||
followed_playlist_id: string | null;
|
||||
/** When the follow began. */
|
||||
created_at: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link FollowRow}. */
|
||||
export function isFollowRow(
|
||||
obj: unknown,
|
||||
): obj is FollowRow {
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* The `invites` table: its row type and its guard.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `invites` table. */
|
||||
export interface InviteRow {
|
||||
/** The invite's token, which registering redeems. */
|
||||
token: string;
|
||||
/** The id of the user who made the invite. */
|
||||
inviter_id: string;
|
||||
/** When the invite was redeemed; null while unused. */
|
||||
used_at: string | null;
|
||||
/** When the invite was made. */
|
||||
created_at: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link InviteRow}. */
|
||||
export function isInviteRow(
|
||||
obj: unknown,
|
||||
): obj is InviteRow {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The `notifications` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link Notification}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type {
|
||||
Notification,
|
||||
@@ -5,17 +12,30 @@ import type {
|
||||
NotificationType,
|
||||
} from "../../shared/notification.ts";
|
||||
|
||||
/** A row of the `notifications` table. */
|
||||
export interface NotificationRow {
|
||||
/** The notification's id. */
|
||||
id: string;
|
||||
/** The id of the user notified. */
|
||||
user_id: string;
|
||||
/** The notification's {@link NotificationType}. */
|
||||
type: string;
|
||||
/** The notification's {@link NotificationData}, as JSON. */
|
||||
data: string;
|
||||
/** 1 once the user has read it. */
|
||||
read: number;
|
||||
/** When the notification was made. */
|
||||
created_at: string;
|
||||
/**
|
||||
* What caused the notification: a second one with the same
|
||||
* `(user_id, source_key)` isn't stored. Null when duplicates are allowed.
|
||||
*/
|
||||
source_key: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link NotificationRow}. */
|
||||
export function isNotificationRow(
|
||||
obj: unknown,
|
||||
): obj is NotificationRow {
|
||||
@@ -30,6 +50,7 @@ export function isNotificationRow(
|
||||
(typeof obj.source_key === "string" || obj.source_key === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Notification}. */
|
||||
export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* The `password_reset_tokens` table: its row type and its guard.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `password_reset_tokens` table. */
|
||||
export interface PasswordResetTokenRow {
|
||||
/** The reset token. */
|
||||
token: string;
|
||||
/** The id of the user whose password it resets. */
|
||||
user_id: string;
|
||||
/** When the token stops working. */
|
||||
expires_at: string;
|
||||
/** When the token was used; null while unused. */
|
||||
used_at: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */
|
||||
export function isPasswordResetTokenRow(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetTokenRow {
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
/**
|
||||
* The `playlists` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Playlist}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/**
|
||||
* A row of the `playlists` table. Queries may add `dump_count` and
|
||||
* `owner_username`, which {@link playlistRowToApi} carries over.
|
||||
*/
|
||||
export interface PlaylistRow {
|
||||
/** The playlist's id. */
|
||||
id: string;
|
||||
/** The owner's id. */
|
||||
user_id: string;
|
||||
/** The playlist's title. */
|
||||
title: string;
|
||||
/** The playlist's URL slug. */
|
||||
slug: string | null;
|
||||
/** The owner's description, in Markdown. */
|
||||
description: string | null;
|
||||
/** 1 when anyone may see the playlist, 0 when only its owner does. */
|
||||
is_public: number;
|
||||
/** When the playlist was created. */
|
||||
created_at: string;
|
||||
/** When the playlist was last changed, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The cover image's type, null without one. */
|
||||
image_mime: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PlaylistRow}. */
|
||||
export function isPlaylistRow(
|
||||
obj: unknown,
|
||||
): obj is PlaylistRow {
|
||||
@@ -32,6 +54,7 @@ export function isPlaylistRow(
|
||||
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Playlist}. */
|
||||
export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The `users` table: its row type, its guard, its mappers to and from the
|
||||
* stored {@link User}, and the views of a user the API sends.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import {
|
||||
isRole,
|
||||
@@ -8,35 +15,54 @@ import {
|
||||
|
||||
/** A user as stored: what they see of themselves, plus the password hash. */
|
||||
export interface User extends SelfUser {
|
||||
/** The scrypt hash of the password, as `hash.salt`. */
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
/** Returns what anyone may see of `user`: no hash, no email. */
|
||||
export function toPublicUser(
|
||||
{ passwordHash: _, email: _email, ...user }: User,
|
||||
): PublicUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Returns what `user` sees of themselves: everything but the hash. */
|
||||
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** A row of the `users` table. */
|
||||
export interface UserRow {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
/** The user's name, unique. */
|
||||
username: string;
|
||||
/** The scrypt hash of the password, as `hash.salt`. */
|
||||
password_hash: string;
|
||||
/** The user's role. */
|
||||
role: Role;
|
||||
/** When the user registered. */
|
||||
created_at: string;
|
||||
/** When the profile was last changed, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The avatar's type, null without one. */
|
||||
avatar_mime: string | null;
|
||||
/** The user's profile text, in Markdown. */
|
||||
description: string | null;
|
||||
/** The id of the user whose invite they registered with. */
|
||||
invited_by: string | null;
|
||||
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
||||
/**
|
||||
* The inviter's name; present only when the query joins it (`LEFT JOIN users
|
||||
* i ON i.id = u.invited_by`).
|
||||
*/
|
||||
invited_by_username: string | null;
|
||||
/** The user's email address. */
|
||||
email: string;
|
||||
[key: string]: SQLOutputValue; // Index signature
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link UserRow}. */
|
||||
export function isUserRow(obj: unknown): obj is UserRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -56,6 +82,7 @@ export function isUserRow(obj: unknown): obj is UserRow {
|
||||
"email" in obj && typeof obj.email === "string";
|
||||
}
|
||||
|
||||
/** Converts a row to the stored {@link User}. */
|
||||
export function userRowToApi(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -73,6 +100,7 @@ export function userRowToApi(row: UserRow): User {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link User} back to a row, without the join-only columns. */
|
||||
export function userApiToRow(user: User): UserRow {
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
|
||||
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* Resolves a Bandcamp page to its streamable tracks, at play time.
|
||||
*
|
||||
@@ -11,16 +5,31 @@ import type { Tralbum } from "../../shared/player.ts";
|
||||
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
|
||||
* is. This service is the play-time counterpart: the frontend asks for a page,
|
||||
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
|
||||
* that is about to expire mid-listen. */
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
|
||||
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* How long a resolved page is cached: comfortably inside the 24h signature
|
||||
* life, so a cache hit is never a URL that is about to expire mid-listen.
|
||||
*/
|
||||
const TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** The most pages cached; the oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
/** Resolved pages, by {@link cacheKey}. */
|
||||
const cache = new Map<string, Tralbum>();
|
||||
|
||||
/** Cache key: a page is the same page regardless of query, fragment or case. */
|
||||
/**
|
||||
* Returns the cache key of `url`: a page is the same page regardless of query,
|
||||
* fragment or case.
|
||||
*/
|
||||
function cacheKey(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -52,10 +61,14 @@ function writeCache(key: string, value: Tralbum): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse a Bandcamp page.
|
||||
* Fetches a Bandcamp page and returns its tracklist, cached.
|
||||
*
|
||||
* `force` bypasses the cache — used by the client's re-resolve path, so a
|
||||
* cached entry whose URLs have started 403ing can't keep being handed back.
|
||||
*
|
||||
* @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL,
|
||||
* 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when
|
||||
* the page has nothing streamable.
|
||||
*/
|
||||
export async function resolveBandcamp(
|
||||
pageUrl: string,
|
||||
|
||||
@@ -13,17 +13,20 @@
|
||||
* The shape is undocumented, so every field is treated as optional and the
|
||||
* whole parse is best-effort: a `null` return means "not a Bandcamp page",
|
||||
* which callers turn into a fallback to the iframe embed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
|
||||
|
||||
/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that
|
||||
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
|
||||
* match this and keep using the embed. */
|
||||
/**
|
||||
* The hostnames Bandcamp serves its own sites from, anchored at the end so that `bandcamp.com.example.org` cannot pass. Artists on custom
|
||||
* domains do not match this and keep using the embed.
|
||||
*/
|
||||
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
|
||||
|
||||
/**
|
||||
* Decode HTML entities in a single pass.
|
||||
* Decodes HTML entities in a single pass.
|
||||
*
|
||||
* The single pass is the point: `decodeHtmlEntities` in remote/html.ts
|
||||
* replaces `&` before `"`, so an escaped-ampersand-then-quot sequence
|
||||
@@ -50,7 +53,7 @@ function decodeEntitiesOnce(input: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve Bandcamp's protocol-relative and root-relative art URLs. */
|
||||
/** Returns the URL of the large square artwork numbered `artId`. */
|
||||
function artworkUrl(artId: unknown): string | undefined {
|
||||
if (typeof artId !== "number" && typeof artId !== "string") return undefined;
|
||||
const id = String(artId).trim();
|
||||
@@ -60,7 +63,7 @@ function artworkUrl(artId: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the `data-tralbum` blob out of a Bandcamp page and normalise it.
|
||||
* Pulls the `data-tralbum` blob out of a Bandcamp page and normalises it.
|
||||
* Returns null when the page has no blob, no audio, or nothing parseable.
|
||||
*/
|
||||
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
|
||||
@@ -114,6 +117,7 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Checks whether `url` is an https URL on Bandcamp's bcbits.com CDN. */
|
||||
function isBcbitsUrl(url: string): boolean {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* The guarded fetch every request to another site goes through: each hop must
|
||||
* resolve to a public address, the whole exchange has a deadline, and pages are
|
||||
* read up to a cap.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts";
|
||||
import { isSelfUrl } from "../lib/self-url.ts";
|
||||
|
||||
@@ -12,7 +20,8 @@ const FETCH_HEADERS = {
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
|
||||
* Fetches `url` through a `curl --insecure` subprocess and returns 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.
|
||||
@@ -91,7 +100,7 @@ async function fetchViaCurl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Most bytes of a fetched page that are read. Metadata lives in the <head>,
|
||||
* The 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;
|
||||
@@ -161,7 +170,7 @@ export async function readTextCapped(
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** One request, no redirects followed. The timeout covers the body too. */
|
||||
/** Makes one request, following no redirects. The timeout covers the body too. */
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
@@ -203,9 +212,14 @@ async function fetchOnce(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Fetches a user-supplied URL, following up to five redirects. Every hop — the
|
||||
* URL itself and each redirect — must resolve to a public address (see
|
||||
* {@link resolvePublicAddress}), so a link or a redirect can't point the
|
||||
* server at its own network.
|
||||
*
|
||||
* @throws {TypeError} When a hop isn't http(s), there are too many redirects,
|
||||
* or the request fails.
|
||||
* @throws {Error} When a hop's host is private or can't be resolved.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
@@ -227,6 +241,10 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `raw` is an http(s) URL a dump may link to: not on a private
|
||||
* host, unless it is one of this site's own pages.
|
||||
*/
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Scraping helpers: the metadata a link preview reads from a page's HTML —
|
||||
* meta and OG tags, the title, JSON-LD, images, icons and brand colors.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Decodes the named and numeric HTML entities in `str`. */
|
||||
function decodeHtmlEntities(str: string): string {
|
||||
return str
|
||||
.replace(/&/gi, "&")
|
||||
@@ -13,8 +21,8 @@ function decodeHtmlEntities(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first `<meta>` tag whose `attr` equals `value` and return its
|
||||
* `content` (either attribute ordering). Each tag is matched in isolation so
|
||||
* Returns the `content` of the first `<meta>` tag whose `attr` equals `value`
|
||||
* (either attribute ordering). Each tag is matched in isolation so
|
||||
* the `content` capture can never span across tag boundaries.
|
||||
*/
|
||||
function extractMetaContent(
|
||||
@@ -36,6 +44,7 @@ function extractMetaContent(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns the content of `<meta property="og:<tag>">`. */
|
||||
export function extractOgTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
@@ -43,7 +52,10 @@ export function extractOgTag(
|
||||
return extractMetaContent(html, "property", `og:${tag}`);
|
||||
}
|
||||
|
||||
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
|
||||
/**
|
||||
* Returns the content of `<meta name="…" content="…">` (both attribute
|
||||
* orderings).
|
||||
*/
|
||||
export function extractMetaName(
|
||||
html: string,
|
||||
name: string,
|
||||
@@ -51,7 +63,7 @@ export function extractMetaName(
|
||||
return extractMetaContent(html, "name", name);
|
||||
}
|
||||
|
||||
/** Extract the text content of the `<title>` element. */
|
||||
/** Returns 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;
|
||||
@@ -60,7 +72,7 @@ export function extractPageTitle(html: string): string | undefined {
|
||||
// ── Brand color helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the page's `theme-color`.
|
||||
* Returns 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
|
||||
@@ -88,7 +100,7 @@ export function extractThemeColor(html: string): string | undefined {
|
||||
return unscoped ?? light ?? any;
|
||||
}
|
||||
|
||||
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
|
||||
/** Returns 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;
|
||||
@@ -101,8 +113,10 @@ export function extractMaskIconColor(html: string): string | undefined {
|
||||
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.
|
||||
/**
|
||||
* 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",
|
||||
@@ -131,7 +145,7 @@ function clampByte(n: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
|
||||
* Normalizes 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.
|
||||
*/
|
||||
@@ -168,13 +182,20 @@ export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
|
||||
// ── JSON-LD helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
type JsonLdResult = {
|
||||
/** What a page's JSON-LD says about it. */
|
||||
export interface JsonLdResult {
|
||||
/** The node's `name` or `headline`. */
|
||||
title?: string;
|
||||
/** The node's `description`. */
|
||||
description?: string;
|
||||
/**
|
||||
* The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute
|
||||
* URL.
|
||||
*/
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ldString(v: unknown): string | undefined {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
@@ -216,7 +237,7 @@ function ldExtractNode(data: unknown): JsonLdResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every `<script type="application/ld+json">` block and return the first
|
||||
* Parses every `<script type="application/ld+json">` block and returns the first
|
||||
* node that yields a title or image. Handles `@graph`, arrays, and the common
|
||||
* `image` shapes (string, string[], ImageObject).
|
||||
*/
|
||||
@@ -234,7 +255,7 @@ export function extractJsonLd(html: string): JsonLdResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `src` of the first `<img>` whose declared width or height is at
|
||||
* Returns 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(
|
||||
@@ -262,8 +283,8 @@ export function extractLargeImage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
||||
* them by declared size (largest wins), and return the best resolved URL.
|
||||
* Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks
|
||||
* them by declared size (largest wins), and returns 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
|
||||
@@ -309,12 +330,7 @@ export function extractBestIcon(
|
||||
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.
|
||||
*/
|
||||
/** Filename tokens that mark an image as UI chrome rather than content. */
|
||||
const UI_IMAGE_KEYWORDS = new Set([
|
||||
"logo",
|
||||
"icon",
|
||||
@@ -333,6 +349,10 @@ const UI_IMAGE_KEYWORDS = new Set([
|
||||
"background",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Checks whether `src` looks like UI chrome: an SVG, or a filename with one of
|
||||
* the {@link UI_IMAGE_KEYWORDS}.
|
||||
*/
|
||||
function isUiImage(src: string): boolean {
|
||||
if (/\.svg(\?|$)/i.test(src)) return true;
|
||||
const filename = src.split("?")[0].split("/").pop() ?? "";
|
||||
@@ -344,7 +364,7 @@ function isUiImage(src: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `src` of the first `<img>` that looks like content rather than UI
|
||||
* Returns 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.
|
||||
@@ -370,8 +390,8 @@ export function extractFirstContentImage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`,
|
||||
* resolved to an absolute URL using `baseUrl`.
|
||||
* Returns the `href` of the first `<link rel="…">` whose rel contains
|
||||
* `relFragment`, resolved to an absolute URL using `baseUrl`.
|
||||
*/
|
||||
export function extractLinkHref(
|
||||
html: string,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* The image proxy's fetch and cache: remote thumbnails are served through
|
||||
* this site, so viewers' browsers never contact the hosts.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from "./fetch.ts";
|
||||
|
||||
/** An image fetched for the proxy. */
|
||||
export interface ProxiedImage {
|
||||
/** The image's bytes. */
|
||||
bytes: Uint8Array;
|
||||
/** The image's MIME type, as its host sent it. */
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
@@ -16,11 +26,15 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
"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.
|
||||
/** How long a proxied image is served from the cache. */
|
||||
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/** The cache's size bound, in image bytes. */
|
||||
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
/**
|
||||
* 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. Map order is recency order.
|
||||
*/
|
||||
const imageCache = new Map<
|
||||
string,
|
||||
{ bytes: Uint8Array; contentType: string; at: number }
|
||||
@@ -63,16 +77,17 @@ function writeImageCache(
|
||||
imageCacheBytes += bytes.byteLength;
|
||||
}
|
||||
|
||||
/** Forget every proxied image (tests). */
|
||||
/** Forgets every proxied image (for the 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.
|
||||
* Returns 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 {Error} When the host can't be reached (see {@link fetchWithTimeout}).
|
||||
*/
|
||||
export async function fetchProxiedImage(
|
||||
url: string,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The Bandcamp link provider: previews from a release page's OG tags.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
@@ -5,6 +11,7 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
|
||||
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||
|
||||
/** The provider for bandcamp.com pages, with the embed player from `og:video`. */
|
||||
export const bandcampProvider: RichContentProvider = {
|
||||
name: "bandcamp",
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The fallback link provider, for any page no other provider claims.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import {
|
||||
@@ -14,6 +20,11 @@ import {
|
||||
} from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
|
||||
/**
|
||||
* The provider for any HTML page, previewed from its OG and Twitter tags, JSON-LD, and markup:
|
||||
* title, description, artwork, icon and brand color. Matches every URL, so it
|
||||
* must come last.
|
||||
*/
|
||||
export const genericProvider: RichContentProvider = {
|
||||
name: "generic",
|
||||
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/**
|
||||
* The interface every link provider implements.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
|
||||
/** Metadata for the links one kind of site serves. */
|
||||
export interface RichContentProvider {
|
||||
/** The provider's name, for logs. */
|
||||
name: string;
|
||||
/** Checks whether this provider handles `url`. */
|
||||
matches(url: string): boolean;
|
||||
/**
|
||||
* Fetches `url`'s preview. Rejects when the page can't be fetched; a page
|
||||
* that isn't HTML yields a preview with no metadata.
|
||||
*/
|
||||
fetch(url: string): Promise<RichContent>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The link provider for this site's own pages, read from the database
|
||||
* instead of fetched.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { getDump } from "../../services/dump-service.ts";
|
||||
@@ -10,14 +17,21 @@ const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
const USER_RE = /^\/users\/([^/]+)$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
|
||||
// Internal links carry gerbeur's own branding, so an art-less one still gets a
|
||||
// recognizable placeholder instead of a generic tint.
|
||||
/**
|
||||
* Internal links carry gerbeur's own branding, so an art-less one still gets a
|
||||
* recognizable placeholder instead of a generic tint.
|
||||
*/
|
||||
const SELF_BRANDING = {
|
||||
siteName: "gerbeur",
|
||||
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
|
||||
accentColor: THEME_COLOR,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The provider for links to this site's dump, user and playlist pages, read
|
||||
* from the database. Its `fetch` rejects with the services' `APIException` (404) for a
|
||||
* page that doesn't exist or is private.
|
||||
*/
|
||||
export const selfProvider: RichContentProvider = {
|
||||
name: "self",
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* The SoundCloud link provider: previews from a track page's OG tags.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
|
||||
/** The provider for soundcloud.com pages, with SoundCloud's embed player. */
|
||||
export const soundcloudProvider: RichContentProvider = {
|
||||
name: "soundcloud",
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* The YouTube link provider: videos, playlists and channels.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
|
||||
/** Returns the id of the video a youtu.be or youtube.com URL points at. */
|
||||
function extractVideoId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -25,6 +32,7 @@ function extractVideoId(url: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns the `list` parameter of a URL: the playlist it plays. */
|
||||
function extractPlaylistId(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).searchParams.get("list");
|
||||
@@ -34,7 +42,7 @@ function extractPlaylistId(url: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the playback offset in seconds from a YouTube URL.
|
||||
* Returns the playback offset in seconds of a YouTube URL.
|
||||
* Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start`
|
||||
* (plain seconds). Returns null when absent or unparseable.
|
||||
*/
|
||||
@@ -57,7 +65,10 @@ function extractStartSeconds(url: string): number | null {
|
||||
Number(hms[3] ?? 0);
|
||||
}
|
||||
|
||||
/** Matches /channel/UC…, /@handle, /c/name, /user/name */
|
||||
/**
|
||||
* Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or
|
||||
* /user/name.
|
||||
*/
|
||||
function extractChannelPath(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -77,6 +88,10 @@ function extractChannelPath(url: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint;
|
||||
* empty when that fails.
|
||||
*/
|
||||
async function fetchOEmbed(
|
||||
url: string,
|
||||
): Promise<{ title?: string; thumbnailUrl?: string }> {
|
||||
@@ -99,6 +114,10 @@ async function fetchOEmbed(
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider for YouTube videos (with their playlist and start time in the embed),
|
||||
* playlists (as a `videoseries` embed) and channels (not embeddable).
|
||||
*/
|
||||
export const youtubeProvider: RichContentProvider = {
|
||||
name: "youtube",
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* image proxy, Bandcamp resolution): the server must not become a way to reach
|
||||
* addresses only it can see — loopback, the LAN, container networks, or the
|
||||
* cloud metadata endpoint at 169.254.169.254.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
function parseIPv4(s: string): number[] | null {
|
||||
@@ -25,7 +27,7 @@ function isPrivateIPv4([a, b]: number[]): boolean {
|
||||
a >= 224; // multicast, reserved, broadcast
|
||||
}
|
||||
|
||||
/** 16 bytes, or null when `s` isn't an IPv6 address. */
|
||||
/** Parses `s` into its 16 bytes, or null when it isn't an IPv6 address. */
|
||||
function parseIPv6(s: string): number[] | null {
|
||||
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
|
||||
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
|
||||
@@ -80,7 +82,10 @@ function isPrivateIPv6(b: number[]): boolean {
|
||||
b[0] === 0xff; // multicast
|
||||
}
|
||||
|
||||
/** True for an IP literal outside the public internet. Non-IPs are false. */
|
||||
/**
|
||||
* Checks whether `ip` is an IP literal outside the public internet. Non-IPs
|
||||
* are false.
|
||||
*/
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = parseIPv4(ip);
|
||||
if (v4) return isPrivateIPv4(v4);
|
||||
@@ -92,13 +97,16 @@ function isIpLiteral(host: string): boolean {
|
||||
return parseIPv4(host) !== null || parseIPv6(host) !== null;
|
||||
}
|
||||
|
||||
/** `URL.hostname` keeps the brackets around an IPv6 literal. */
|
||||
/**
|
||||
* Returns `url`'s hostname without the brackets `URL.hostname` keeps around
|
||||
* an IPv6 literal.
|
||||
*/
|
||||
function bareHostname(url: URL): string {
|
||||
return url.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous part of the check: a hostname that is a private IP literal,
|
||||
* Checks whether `url`'s host is private without DNS: a private IP literal,
|
||||
* or a name that is local by definition. Names still need resolving (see
|
||||
* resolvePublicAddress) — this only rejects what is obvious without DNS.
|
||||
*/
|
||||
@@ -116,6 +124,9 @@ export function isPrivateHostname(url: URL): boolean {
|
||||
* The fetch itself resolves again, so a DNS server answering differently the
|
||||
* second time (rebinding) isn't fully excluded; the curl fallback pins the
|
||||
* address this returns.
|
||||
*
|
||||
* @throws {Error} When the host is private, can't be resolved, or resolves to
|
||||
* a private address.
|
||||
*/
|
||||
export async function resolvePublicAddress(url: URL): Promise<string> {
|
||||
if (isPrivateHostname(url)) {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// Pinned Twemoji release.
|
||||
/**
|
||||
* Twemoji glyphs, fetched from the jsDelivr CDN.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The pinned Twemoji release. */
|
||||
const TWEMOJI_VERSION = "16.0.1";
|
||||
|
||||
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
|
||||
/** Fetched glyphs by codepoint, 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).
|
||||
* Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named
|
||||
* `cp` (see `emojiToCodepoint` in lib/site-icons.ts).
|
||||
*
|
||||
* @throws {Error} When the glyph can't be fetched.
|
||||
*/
|
||||
export async function fetchTwemojiSvg(
|
||||
emoji: string,
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { readTextCapped } from "./fetch.ts";
|
||||
import type {
|
||||
YouTubePlaylist,
|
||||
YouTubePlaylistTrack,
|
||||
} from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* Resolves a YouTube playlist to its tracklist, at play time.
|
||||
*
|
||||
@@ -20,17 +12,33 @@ import type {
|
||||
* both the current `lockupViewModel` rows and the older `playlistVideoRenderer`
|
||||
* ones. Only the first page (100 entries) is read; the client appends any
|
||||
* further IDs the player reports, untitled.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { readTextCapped } from "./fetch.ts";
|
||||
import type {
|
||||
YouTubePlaylist,
|
||||
YouTubePlaylistTrack,
|
||||
} from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* How long a resolved playlist is cached. Titles and lengths rarely change; a
|
||||
* stale entry only mislabels a row.
|
||||
*/
|
||||
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** The most playlists cached; the oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
/** Resolved playlists, by list id. */
|
||||
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
|
||||
|
||||
/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */
|
||||
/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */
|
||||
const LIST_ID_RE = /^[\w-]{2,64}$/;
|
||||
|
||||
/** The headers the playlist page is requested with. */
|
||||
const HEADERS = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||
@@ -41,13 +49,13 @@ const HEADERS = {
|
||||
"Cookie": "SOCS=CAI",
|
||||
};
|
||||
|
||||
/** "3:55", "1:02:03" → seconds. */
|
||||
/** Converts a clock like "3:55" or "1:02:03" to seconds. */
|
||||
function parseClock(text: string): number | undefined {
|
||||
if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined;
|
||||
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
|
||||
}
|
||||
|
||||
/** Depth-first walk collecting every value stored under `key`. */
|
||||
/** Collects, depth-first, every value stored under `key` into `out`. */
|
||||
function collect(node: unknown, key: string, out: unknown[]): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) collect(child, key, out);
|
||||
@@ -59,13 +67,17 @@ function collect(node: unknown, key: string, out: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the first string stored under `key`, at any depth. */
|
||||
function firstString(node: unknown, key: string): string | undefined {
|
||||
const found: unknown[] = [];
|
||||
collect(node, key, found);
|
||||
return found.find((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
/** Follows `path` into parsed JSON; undefined wherever a step is missing. */
|
||||
/**
|
||||
* Returns the value at `path` in parsed JSON; undefined wherever a step is
|
||||
* missing.
|
||||
*/
|
||||
function at(node: unknown, ...path: (string | number)[]): unknown {
|
||||
let cur = node;
|
||||
for (const key of path) {
|
||||
@@ -75,6 +87,7 @@ function at(node: unknown, ...path: (string | number)[]): unknown {
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Reads a track from a current `lockupViewModel` row; null for a non-video. */
|
||||
function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
|
||||
if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||
const videoId = at(lockup, "contentId");
|
||||
@@ -96,6 +109,7 @@ function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
|
||||
return { videoId, title, duration };
|
||||
}
|
||||
|
||||
/** Reads a track from an older `playlistVideoRenderer` row. */
|
||||
function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
|
||||
const videoId = at(r, "videoId");
|
||||
const title = at(r, "title", "runs", 0, "text") ??
|
||||
@@ -109,6 +123,10 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tracklist from a playlist page's `ytInitialData`; null when the
|
||||
* page has none or no track in it.
|
||||
*/
|
||||
export function parsePlaylistPage(
|
||||
html: string,
|
||||
listId: string,
|
||||
@@ -145,6 +163,13 @@ export function parsePlaylistPage(
|
||||
return { listId, title, tracks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist `listId` and returns its tracklist, cached.
|
||||
*
|
||||
* @throws {APIException} 400 when `listId` isn't a playlist id, 502 when
|
||||
* YouTube can't be reached or answers with an error, 404 when the playlist has
|
||||
* no tracks to read (private, deleted, or a mix).
|
||||
*/
|
||||
export async function resolveYouTubePlaylist(
|
||||
listId: string,
|
||||
): Promise<YouTubePlaylist> {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The attachment routes, for images pasted into Markdown bodies.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
@@ -13,7 +19,15 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router();
|
||||
/**
|
||||
* The attachment routes, for images pasted into Markdown bodies:
|
||||
*
|
||||
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
|
||||
* most 5 MB) and answers its id; it is bound to a resource once a saved body
|
||||
* links it.
|
||||
* - `GET /api/attachments/:attachmentId` — public. Serves the image.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The avatar routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
|
||||
@@ -15,7 +21,15 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router();
|
||||
/**
|
||||
* The avatar routes:
|
||||
*
|
||||
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
|
||||
* `file`, at most 5 MB), announces it, and answers the caller's user.
|
||||
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
|
||||
* one.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
const authPayload = ctx.state.user;
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
/**
|
||||
* The Bandcamp route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
const bandcampRouter = new Router();
|
||||
|
||||
/**
|
||||
* Resolve a Bandcamp page to its streamable tracks.
|
||||
* The Bandcamp route, for the native player:
|
||||
*
|
||||
* Public, like /api/preview: it only reads a public page and returns URLs the
|
||||
* same page already hands any visitor. Errors are thrown as APIException and
|
||||
* rendered by errorMiddleware; the client treats any failure as "fall back to
|
||||
* the iframe embed".
|
||||
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page
|
||||
* to its streamable tracks, at play time; `force=1` bypasses the cache.
|
||||
*/
|
||||
const bandcampRouter: Router = new Router();
|
||||
|
||||
// Public: it only reads a public page and returns URLs the same page already
|
||||
// hands any visitor. Errors are thrown as APIException and rendered by
|
||||
// errorMiddleware; the client treats any failure as "fall back to the iframe
|
||||
// embed".
|
||||
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const force = ctx.request.url.searchParams.get("force") === "1";
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The category routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
@@ -25,7 +31,20 @@ import {
|
||||
updateCategory,
|
||||
} from "../services/category-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/categories" });
|
||||
/**
|
||||
* The category routes:
|
||||
*
|
||||
* - `GET /api/categories` — public. Answers every category, in order.
|
||||
* - `GET /api/categories/:slug` — public. Answers one category.
|
||||
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
|
||||
* a page of its dumps, newest first, with the caller's private ones.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category.
|
||||
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
|
||||
* category.
|
||||
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
|
||||
* category; its dumps stay.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/categories" });
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
const responseBody: APIResponse<Category[]> = {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The chat history route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
@@ -7,13 +13,19 @@ import {
|
||||
getRecentChatMessages,
|
||||
} from "../services/chat-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/chat" });
|
||||
/**
|
||||
* The chat history route; messages are posted and edited over the socket (see
|
||||
* routes/ws.ts):
|
||||
*
|
||||
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in.
|
||||
* Answers messages in ascending order: the most recent by default; with
|
||||
* `before`, the page of older messages preceding that cursor ("load older");
|
||||
* with `after`, the messages following it (resync after a reconnect). A
|
||||
* cursor is an ISO `created_at` plus the message id to break ties; without
|
||||
* `afterId` the `after` timestamp itself is included.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/chat" });
|
||||
|
||||
// GET /api/chat — recent messages (ascending). `limit` caps the page size.
|
||||
// Cursors (ISO created_at, plus the message id to break ties):
|
||||
// before/beforeId — the page of older messages preceding it ("load older");
|
||||
// after/afterId — the messages following it (resync after a reconnect);
|
||||
// without afterId the timestamp itself is included.
|
||||
router.get("/", authMiddleware, (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const limitParam = params.get("limit");
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The comment routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
@@ -31,9 +37,23 @@ import {
|
||||
broadcastCommentUpdated,
|
||||
} from "../services/ws-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api" });
|
||||
/**
|
||||
* The comment routes. A private dump's comments exist only for its poster, and
|
||||
* changes to them aren't broadcast:
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
||||
* on a dump, oldest first.
|
||||
* - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying
|
||||
* to `parentId` if given.
|
||||
* - `PATCH /api/comments/:commentId` — signed in, as its author or with
|
||||
* `comment:moderate`. Edits a comment.
|
||||
* - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in.
|
||||
* Answers a page of the users who like a comment.
|
||||
* - `DELETE /api/comments/:commentId` — signed in, as its author or with
|
||||
* `comment:moderate`. Deletes a comment, leaving its place in the thread.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api" });
|
||||
|
||||
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
|
||||
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
@@ -45,7 +65,6 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/dumps/:dumpId/comments — auth required
|
||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
@@ -69,7 +88,6 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// PATCH /api/comments/:commentId — auth required
|
||||
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
@@ -92,7 +110,6 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/comments/:commentId/likers — optional auth (to access private dump's comment likers)
|
||||
router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dumpId = getCommentDumpId(ctx.params.commentId);
|
||||
@@ -113,7 +130,6 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/comments/:commentId — auth required
|
||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The dump routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
@@ -38,13 +44,48 @@ import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/dumps" });
|
||||
/**
|
||||
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
|
||||
* for its poster:
|
||||
*
|
||||
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
|
||||
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
|
||||
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
|
||||
* pointing at the same thing as `url`, for the create form's duplicate hint.
|
||||
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
|
||||
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
|
||||
* page of the users who upvoted it.
|
||||
* - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps
|
||||
* linked to or from it.
|
||||
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
|
||||
* playlists it appears in.
|
||||
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
|
||||
* dumps, newest first.
|
||||
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces a file dump's upload (multipart).
|
||||
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
|
||||
* `dump:moderate`. Updates its title, comment, visibility, categories, and a
|
||||
* link's URL.
|
||||
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
|
||||
* MB).
|
||||
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Removes its custom thumbnail.
|
||||
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
|
||||
* with `dump:moderate`. Fetches a link's preview again.
|
||||
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
|
||||
* `dump:moderate`. Deletes it.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
// Every route here takes a slug or an id; the services below them key on the
|
||||
// id, so mutations always pass `dump.id` from the resolved dump, never the raw
|
||||
// parameter.
|
||||
|
||||
// Categories arrive on the multipart create path as a JSON-encoded string field.
|
||||
/**
|
||||
* Reads the category ids that arrive on the multipart create path as a
|
||||
* JSON-encoded string field; empty when it is missing or malformed.
|
||||
*/
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The route serving file dumps' uploads.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
@@ -6,7 +12,14 @@ import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/files" });
|
||||
/**
|
||||
* The route serving file dumps' uploads:
|
||||
*
|
||||
* - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its
|
||||
* poster's media `token`. Serves the file, inline when that is safe and as a
|
||||
* download otherwise, with byte ranges.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/files" });
|
||||
|
||||
/**
|
||||
* Builds an RFC 6266 `Content-Disposition` value that is always a valid
|
||||
@@ -28,15 +41,18 @@ function contentDisposition(
|
||||
}`;
|
||||
}
|
||||
|
||||
// The MIME type of an upload is whatever the uploader's browser claimed, and
|
||||
// these responses come from the app's own origin — where the session token
|
||||
// lives. Only types a browser renders without running the file's own code are
|
||||
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
|
||||
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
|
||||
// or read the file still work.
|
||||
/**
|
||||
* The types served inline. The MIME type of an upload is whatever the
|
||||
* uploader's browser claimed, and these responses come from the app's own
|
||||
* origin — where the session token lives. Only types a browser renders without
|
||||
* running the file's own code are served inline; anything else (text/html,
|
||||
* image/svg+xml, XML, …) downloads. <img>, <audio> and fetch() ignore
|
||||
* Content-Disposition, so previews that embed or read the file still work.
|
||||
*/
|
||||
const INLINE_MIME_RE =
|
||||
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
|
||||
|
||||
/** Checks whether a file of type `mime` may be served inline. */
|
||||
function isInlineSafe(mime: string): boolean {
|
||||
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The follow routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
|
||||
@@ -15,9 +21,12 @@ import {
|
||||
} from "../services/follow-service.ts";
|
||||
import { getCategoryIdBySlug } from "../services/category-service.ts";
|
||||
|
||||
// Resolves the optional ?category=<slug> filter. Returns the matching category
|
||||
// id, or "missing" when a slug was given but matches nothing (so the feed comes
|
||||
// back empty rather than unfiltered), or undefined when no filter was requested.
|
||||
/**
|
||||
* Resolves the optional ?category=<slug> filter. Returns the matching category
|
||||
* id, or "missing" when a slug was given but matches nothing (so the feed comes
|
||||
* back empty rather than unfiltered), or undefined when no filter was
|
||||
* requested.
|
||||
*/
|
||||
function resolveCategoryFilter(
|
||||
params: URLSearchParams,
|
||||
): string | "missing" | undefined {
|
||||
@@ -26,20 +35,37 @@ function resolveCategoryFilter(
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
}
|
||||
|
||||
/** The feed a filter naming no category yields. */
|
||||
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
||||
|
||||
const router = new Router({ prefix: "/api/follows" });
|
||||
/**
|
||||
* The follow routes, all for what the caller follows:
|
||||
*
|
||||
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
|
||||
* playlists they follow.
|
||||
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
|
||||
* page of the public dumps of the users they follow, newest first, optionally
|
||||
* in one category.
|
||||
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
|
||||
* Answers a page of the public dumps in the public playlists they follow,
|
||||
* most recently added first, optionally in one category.
|
||||
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
|
||||
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
|
||||
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
|
||||
* playlist.
|
||||
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
|
||||
* playlist.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/follows" });
|
||||
|
||||
// Static routes first to prevent Oak matching "status"/"feed" as a :param
|
||||
|
||||
// GET /api/follows/status
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// GET /api/follows/feed/users?page=&limit=
|
||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
@@ -54,7 +80,6 @@ router.get("/feed/users", authMiddleware, (ctx) => {
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// GET /api/follows/feed/playlists?page=&limit=
|
||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
@@ -74,25 +99,21 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// POST /api/follows/users/:userId
|
||||
router.post("/users/:userId", authMiddleware, (ctx) => {
|
||||
followUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// DELETE /api/follows/users/:userId
|
||||
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
||||
unfollowUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// POST /api/follows/playlists/:playlistId
|
||||
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// DELETE /api/follows/playlists/:playlistId
|
||||
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The invite routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
@@ -5,9 +11,15 @@ import type { InviteResponse } from "../../shared/user.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/invites" });
|
||||
/**
|
||||
* The invite routes:
|
||||
*
|
||||
* - `POST /api/invites` — signed in. Makes an invite and answers its token.
|
||||
* - `GET /api/invites/:token` — public. Checks an invite is valid, before the
|
||||
* register page shows its form; 404 when it isn't.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/invites" });
|
||||
|
||||
// Create a new invite link (any authenticated user)
|
||||
router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
@@ -20,7 +32,6 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Validate an invite token (used by the register page before showing the form)
|
||||
router.get("/:token", (ctx) => {
|
||||
try {
|
||||
validateInvite(ctx.params.token);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The notification routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
@@ -14,9 +20,16 @@ import {
|
||||
markOneRead,
|
||||
} from "../services/notification-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/notifications" });
|
||||
/**
|
||||
* The notification routes, all for the caller's own notifications:
|
||||
*
|
||||
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
|
||||
* newest first.
|
||||
* - `POST /api/notifications/read-all` — signed in. Marks them all read.
|
||||
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/notifications" });
|
||||
|
||||
// GET /api/notifications?page=N&limit=N
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
@@ -34,7 +47,6 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all
|
||||
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
@@ -43,7 +55,6 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// PATCH /api/notifications/:id/read
|
||||
router.patch("/:id/read", authMiddleware, (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
if (!user) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* The playlist routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
@@ -33,9 +39,35 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router<AuthState>({ prefix: "/api/playlists" });
|
||||
/**
|
||||
* The playlist routes. `:playlistId` takes an id or a slug:
|
||||
*
|
||||
* - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each
|
||||
* of the caller's playlists and whether it holds the dump.
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist.
|
||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||
* with the dumps the caller may see in it; a private one only to its owner.
|
||||
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Updates its title, description and visibility.
|
||||
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Deletes it.
|
||||
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Adds a dump at the top.
|
||||
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Removes a dump.
|
||||
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
|
||||
* - `GET /api/playlists/:playlistId/image?token=` — public; a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover; 404
|
||||
* without one.
|
||||
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Reorders the dumps the caller can see in it.
|
||||
*/
|
||||
const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
|
||||
// GET /api/playlists/by-dump/:dumpId/memberships — must be before /:playlistId
|
||||
// Before /:playlistId, which would otherwise match "by-dump".
|
||||
router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const userId = ctx.state.user.userId;
|
||||
@@ -46,7 +78,6 @@ router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/playlists — create
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
@@ -64,7 +95,6 @@ router.post("/", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/playlists/:playlistId — optional auth
|
||||
router.get("/:playlistId", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const responseBody: APIResponse<PlaylistWithDumps> = {
|
||||
@@ -74,7 +104,6 @@ router.get("/:playlistId", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// PUT /api/playlists/:playlistId — update metadata
|
||||
router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdatePlaylistRequest(body)) {
|
||||
@@ -94,7 +123,6 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/playlists/:playlistId
|
||||
router.delete("/:playlistId", authMiddleware, (ctx) => {
|
||||
deletePlaylist(
|
||||
ctx.params.playlistId,
|
||||
@@ -104,7 +132,6 @@ router.delete("/:playlistId", authMiddleware, (ctx) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// POST /api/playlists/:playlistId/dumps/:dumpId — add dump
|
||||
router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
addDumpToPlaylist(
|
||||
ctx.params.playlistId,
|
||||
@@ -115,7 +142,6 @@ router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// DELETE /api/playlists/:playlistId/dumps/:dumpId — remove dump
|
||||
router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
removeDumpFromPlaylist(
|
||||
ctx.params.playlistId,
|
||||
@@ -126,7 +152,6 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// POST /api/playlists/:playlistId/image — upload playlist image
|
||||
router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
@@ -177,21 +202,27 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/playlists/:playlistId/image — serve playlist image
|
||||
router.get("/:playlistId/image", async (ctx) => {
|
||||
const info = getPlaylistImageInfo(ctx.params.playlistId);
|
||||
// An <img> can't send an Authorization header, so a private playlist's owner
|
||||
// is known by the media token in the query string, as for dump files.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const info = getPlaylistImageInfo(ctx.params.playlistId, viewerId);
|
||||
if (!info) {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
await serveUploadedFile(
|
||||
ctx,
|
||||
`${PLAYLIST_IMAGES_DIR}/${info.id}`,
|
||||
info.imageMime,
|
||||
3600,
|
||||
!info.isPublic,
|
||||
);
|
||||
});
|
||||
|
||||
// PUT /api/playlists/:playlistId/order — reorder
|
||||
router.put("/:playlistId/order", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isReorderPlaylistRequest(body)) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The link preview routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||
import { fetchProxiedImage } from "../remote/image-proxy.ts";
|
||||
@@ -10,7 +16,15 @@ import {
|
||||
import type { LinkPreview } from "../../shared/dump.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
/**
|
||||
* The link preview routes:
|
||||
*
|
||||
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
|
||||
* whether its page could be reached.
|
||||
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through
|
||||
* this site; 400 when it isn't one, 502 when its host can't be reached.
|
||||
*/
|
||||
const previewRouter: Router = new Router();
|
||||
|
||||
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
||||
// use the server to fetch pages for them.
|
||||
@@ -37,6 +51,7 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
/** Answers with a proxied image, sandboxed so it can't run as a document. */
|
||||
function sendImage(
|
||||
ctx: { response: { headers: Headers; body?: unknown } },
|
||||
bytes: Uint8Array,
|
||||
@@ -52,10 +67,8 @@ function sendImage(
|
||||
ctx.response.body = bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy an external image through the server so HTTP thumbnail URLs don't
|
||||
* trigger mixed-content blocks when the frontend is served over HTTPS.
|
||||
*/
|
||||
// Proxies an external image through the server so HTTP thumbnail URLs don't
|
||||
// trigger mixed-content blocks when the frontend is served over HTTPS.
|
||||
previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The search route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
@@ -7,7 +13,14 @@ import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/search" });
|
||||
/**
|
||||
* The search route:
|
||||
*
|
||||
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
|
||||
* matching dumps with their total, and up to 20 matching users and playlists;
|
||||
* private dumps and playlists only the caller's own.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/search" });
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The route serving dumps' thumbnails, generated with ffmpeg where needed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
@@ -11,12 +17,24 @@ import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/thumbnails" });
|
||||
/**
|
||||
* The route serving dumps' thumbnails:
|
||||
*
|
||||
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
|
||||
* thumbnail needs its poster's media `token`. Serves the dump's custom
|
||||
* thumbnail, else one made from its upload: a still of a video, or a
|
||||
* scaled-down copy of an image (the image itself when none can be made); 404
|
||||
* when there is neither.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/thumbnails" });
|
||||
|
||||
// Probed once per process: spawning `ffmpeg -version` on every uncached
|
||||
// thumbnail request cost a process per card.
|
||||
/**
|
||||
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on
|
||||
* every uncached thumbnail request cost a process per card.
|
||||
*/
|
||||
let ffmpegProbe: Promise<boolean> | undefined;
|
||||
|
||||
/** Checks whether ffmpeg is available. */
|
||||
function ffmpegAvailable(): Promise<boolean> {
|
||||
ffmpegProbe ??= (async () => {
|
||||
try {
|
||||
@@ -33,16 +51,23 @@ function ffmpegAvailable(): Promise<boolean> {
|
||||
return ffmpegProbe;
|
||||
}
|
||||
|
||||
// The upload's type is only what the uploader claimed, and ffmpeg probes the
|
||||
// real format from the bytes — a "video" could be an HLS or concat playlist
|
||||
// pointing ffmpeg at local files or internal URLs. Read nothing but the file
|
||||
// itself, and only as the kind of container it's meant to be.
|
||||
/**
|
||||
* The container formats ffmpeg may read a video as. The upload's type is only
|
||||
* what the uploader claimed, and ffmpeg probes the real format from the bytes —
|
||||
* a "video" could be an HLS or concat playlist pointing ffmpeg at local files
|
||||
* or internal URLs. Read nothing but the file itself, and only as the kind of
|
||||
* container it's meant to be.
|
||||
*/
|
||||
const VIDEO_FORMATS =
|
||||
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc";
|
||||
/** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
|
||||
/** Images the feed shows through a scaled-down preview. GIFs keep their
|
||||
* animation (served as they are); anything else has no preview to make. */
|
||||
/**
|
||||
* The image types the feed shows through a scaled-down preview, and the
|
||||
* preview's format. GIFs keep their animation (served as they are); anything
|
||||
* else has no preview to make.
|
||||
*/
|
||||
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/bmp": "jpg",
|
||||
@@ -51,10 +76,17 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/webp": "png",
|
||||
};
|
||||
|
||||
/** Width of an image preview: comfortably covers the widest feed card on a
|
||||
* high-density screen, a fraction of a phone photo's bytes. */
|
||||
/**
|
||||
* The width of an image preview: comfortably covers the widest feed card on a
|
||||
* high-density screen, a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `args` into `outPath`.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails.
|
||||
*/
|
||||
async function runFfmpeg(args: string[], outPath: string): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
// Written beside and renamed over, so a concurrent reader never gets half.
|
||||
@@ -71,6 +103,7 @@ async function runFfmpeg(args: string[], outPath: string): Promise<void> {
|
||||
await Deno.rename(tmpPath, outPath);
|
||||
}
|
||||
|
||||
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
|
||||
function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
return runFfmpeg([
|
||||
"-format_whitelist",
|
||||
@@ -90,6 +123,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */
|
||||
function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
return runFfmpeg([
|
||||
"-format_whitelist",
|
||||
@@ -108,10 +142,17 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
// One ffmpeg run per output: a feed full of cards asking for the same new
|
||||
// still at once waits on a single generation.
|
||||
/**
|
||||
* The generations under way, by output path: one ffmpeg run per output, so a
|
||||
* feed full of cards asking for the same new still at once waits on a single
|
||||
* generation.
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Makes `outPath` with `generate` unless it exists; false when it can't be
|
||||
* made (no ffmpeg, or ffmpeg failed).
|
||||
*/
|
||||
async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The user and account routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
@@ -64,14 +70,54 @@ import {
|
||||
getFollowedUsersByUser,
|
||||
} from "../services/follow-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
/**
|
||||
* The user and account routes. Public profiles never carry the password hash or
|
||||
* the email:
|
||||
*
|
||||
* - `POST /api/users/register` — public, with a valid invite. Creates an
|
||||
* account, sends the welcome email, and answers the user and their tokens.
|
||||
* - `POST /api/users/login` — public. Answers the user and their tokens;
|
||||
* rate-limited per account.
|
||||
* - `GET /api/users/me` — signed in. Answers the caller's own user.
|
||||
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a
|
||||
* session that predates them.
|
||||
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
|
||||
* the account with that address, if any; always answers 200.
|
||||
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
|
||||
* password.
|
||||
* - `POST /api/users/me/change-password` — signed in, with the current
|
||||
* password. Sets a new one, ending every other session, and answers fresh
|
||||
* tokens.
|
||||
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
|
||||
* description and email.
|
||||
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
|
||||
* user's role.
|
||||
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
|
||||
* starts with `q`, for @mention suggestions.
|
||||
* - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id.
|
||||
* - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a
|
||||
* page of the users they follow.
|
||||
* - `GET /api/users/:username/followed-playlists?page=&limit=` — public.
|
||||
* Answers a page of the public playlists they follow.
|
||||
* - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in.
|
||||
* Answers a page of their playlists, the private ones only to themselves.
|
||||
* - `GET /api/users/:username/invitees` — public. Answers the tree of users
|
||||
* they invited.
|
||||
* - `GET /api/users/:username` — public. Answers a user's profile.
|
||||
* - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a
|
||||
* page of their dumps, the private ones only to themselves.
|
||||
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
|
||||
* page of the dumps they upvoted, private ones only to themselves.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/users" });
|
||||
|
||||
// A burst of 10 attempts, then one every 30s.
|
||||
/** Login attempts per account: a burst of 10, then one every 30s. */
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
// The session token, plus the media-only token that goes in file and
|
||||
// thumbnail URLs (see createMediaToken).
|
||||
/**
|
||||
* Issues `user` a session token, plus the media-only token that goes in file
|
||||
* and thumbnail URLs (see {@link createMediaToken}).
|
||||
*/
|
||||
async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
@@ -83,7 +129,6 @@ async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
};
|
||||
}
|
||||
|
||||
// Register a new user (requires a valid invite token)
|
||||
router.post("/register", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
@@ -129,7 +174,6 @@ router.post("/register", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
@@ -179,7 +223,6 @@ router.post("/login", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
try {
|
||||
if (!ctx.state.user) {
|
||||
@@ -206,7 +249,6 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
}
|
||||
});
|
||||
|
||||
// A media token for a session that predates them (see createMediaToken).
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||||
success: true,
|
||||
@@ -215,7 +257,6 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Request a password reset email (unauthenticated; always returns 200)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
@@ -229,7 +270,6 @@ router.post("/request-password-reset", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Consume a reset token and set a new password (unauthenticated)
|
||||
router.post("/reset-password", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const token = fieldOf(body, "token");
|
||||
@@ -246,7 +286,6 @@ router.post("/reset-password", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Change current user's password (requires current password verification)
|
||||
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const currentPassword = fieldOf(body, "currentPassword");
|
||||
@@ -293,7 +332,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Update current user profile (description, etc.)
|
||||
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateUserRequest(body)) {
|
||||
@@ -320,7 +358,6 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Set another user's role. Requires the user:manage permission.
|
||||
router.patch(
|
||||
"/:userId/role",
|
||||
authMiddleware,
|
||||
@@ -355,7 +392,6 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
// User search for @mention autocomplete
|
||||
router.get("/search", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
const responseBody: APIResponse<UserSuggestion[]> = {
|
||||
@@ -365,7 +401,6 @@ router.get("/search", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
@@ -374,7 +409,6 @@ router.get("/by-id/:userId", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed users for a user (public)
|
||||
router.get("/:username/followed-users", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -389,7 +423,6 @@ router.get("/:username/followed-users", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed playlists for a user (public only)
|
||||
router.get("/:username/followed-playlists", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -401,7 +434,6 @@ router.get("/:username/followed-playlists", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Playlists by user (optional auth: include private only if requester === owner)
|
||||
router.get("/:username/playlists", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
@@ -419,7 +451,6 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Invite tree for a user (public)
|
||||
router.get("/:username/invitees", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
@@ -429,7 +460,6 @@ router.get("/:username/invitees", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
router.get("/:username", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
@@ -438,7 +468,6 @@ router.get("/:username", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||
router.get("/:username/dumps", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
@@ -457,7 +486,6 @@ router.get("/:username/dumps", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||
router.get("/:username/votes", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The WebSocket route, and the handling of what clients send over it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
@@ -50,8 +56,18 @@ import type {
|
||||
} from "../../shared/ws.ts";
|
||||
import type { User } from "../model/user.ts";
|
||||
|
||||
const router = new Router();
|
||||
/**
|
||||
* The WebSocket route:
|
||||
*
|
||||
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an
|
||||
* invalid one leaves it anonymous. Opens the socket that carries presence,
|
||||
* live updates, votes, comment likes and the chat (see
|
||||
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
|
||||
* from an origin not in {@link ALLOWED_ORIGINS}.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
/** Checks whether a socket may be opened from `origin`. */
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
return ALLOWED_ORIGINS.includes(origin);
|
||||
}
|
||||
@@ -132,6 +148,7 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Parses one message from a client and dispatches it by `type`. */
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
@@ -195,11 +212,15 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
|
||||
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's upvote, acknowledges it, and broadcasts the new
|
||||
* count.
|
||||
*/
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
dumpId: unknown,
|
||||
@@ -240,6 +261,10 @@ function handleVote(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's like on a comment, acknowledges it, and
|
||||
* broadcasts the new count.
|
||||
*/
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
commentId: unknown,
|
||||
@@ -278,14 +303,21 @@ function handleCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
// Sending, editing and deleting share one bucket: a burst of 8, then one every
|
||||
// 1.5s — room for a quick back-and-forth, not for a flood.
|
||||
/**
|
||||
* Chat writes per user. Sending, editing and deleting share one bucket: a
|
||||
* burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a
|
||||
* flood.
|
||||
*/
|
||||
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
|
||||
/** Typing signals per user. */
|
||||
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
// Read markers are cheap for the client to send often; past this they're
|
||||
// dropped, and the next one catches up.
|
||||
/**
|
||||
* Read markers per user. They are cheap for the client to send often; past
|
||||
* this they're dropped, and the next one catches up.
|
||||
*/
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
@@ -294,8 +326,10 @@ function sendChatError(
|
||||
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
||||
}
|
||||
|
||||
// Validates the common preconditions of a chat write: signed in and under the
|
||||
// rate limit. Returns the user id, or undefined after reporting why not.
|
||||
/**
|
||||
* Validates the common preconditions of a chat write: signed in and under the
|
||||
* rate limit. Returns the user id, or undefined after reporting why not.
|
||||
*/
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
sendChatError(client, "Authentication required", nonce);
|
||||
@@ -312,12 +346,17 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */
|
||||
function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
return typeof value === "string" && value.length <= maxLength
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
|
||||
* already reading the chat.
|
||||
*/
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
body: unknown,
|
||||
@@ -364,9 +403,12 @@ function handleChatSend(
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the role from the DB on every moderation check rather than trusting the
|
||||
// one captured at connect: a demoted moderator loses the power right away, not
|
||||
// whenever their socket next reconnects.
|
||||
/**
|
||||
* Checks whether the client's user may moderate the chat. Reads the role from
|
||||
* the DB on every check rather than trusting the one captured at connect: a
|
||||
* demoted moderator loses the power right away, not whenever their socket next
|
||||
* reconnects.
|
||||
*/
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
try {
|
||||
@@ -377,6 +419,7 @@ function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -412,6 +455,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a chat message and broadcasts it. */
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -431,8 +475,10 @@ function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Moves the user's read marker and tells all their sockets, so every tab and
|
||||
// device drops its badge together.
|
||||
/**
|
||||
* Moves the user's read marker and tells all their sockets, so every tab and
|
||||
* device drops its badge together.
|
||||
*/
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* The YouTube route.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||
import type { YouTubePlaylist } from "../../shared/player.ts";
|
||||
|
||||
const youtubeRouter = new Router();
|
||||
|
||||
/**
|
||||
* Resolve a YouTube playlist ID to its tracklist (titles and lengths).
|
||||
* The YouTube route, for the player's tracklist:
|
||||
*
|
||||
* Public, like /api/bandcamp/tracks: it only reads a public page. The client
|
||||
* treats any failure as "show the embed without a tracklist".
|
||||
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id
|
||||
* to its tracklist (titles and lengths).
|
||||
*/
|
||||
const youtubeRouter: Router = new Router();
|
||||
|
||||
// Public, like /api/bandcamp/tracks: it only reads a public page. The client
|
||||
// treats any failure as "show the embed without a tracklist".
|
||||
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||
const responseBody: APIResponse<YouTubePlaylist> = {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Generates the favicon set + manifest from the runtime GERBEUR_SITE_EMOJI.
|
||||
// Runs before the server starts, so changing the emoji only requires a restart
|
||||
// — no rebuild.
|
||||
//
|
||||
// The output directory is passed as the first argument (relative to cwd):
|
||||
// - prod (`deno task start` / `serve`): `dist` — what the Oak server serves
|
||||
// - dev (`deno task dev`): `public` — what the Vite server serves
|
||||
// Defaults to `dist` when omitted.
|
||||
/**
|
||||
* Generates the favicon set and manifest from the runtime `GERBEUR_SITE_EMOJI`.
|
||||
* Runs before the server starts, so changing the emoji only requires a restart
|
||||
* — no rebuild.
|
||||
*
|
||||
* The output directory is passed as the first argument (relative to cwd), and
|
||||
* defaults to `dist`:
|
||||
* - prod (`deno task start` / `serve`): `dist` — what the Oak server serves
|
||||
* - dev (`deno task dev`): `public` — what the Vite server serves
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { OG_SITE_NAME, SITE_EMOJI, THEME_COLOR } from "../config.ts";
|
||||
import { generateSiteIcons } from "../lib/site-icons.ts";
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* Images pasted into Markdown bodies (chat, comments, descriptions): their
|
||||
* records, and binding them to the resource whose body links them, so the
|
||||
* startup purge keeps them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { db, transaction } from "../db/connection.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
|
||||
/** Records the upload of attachment `id`, not yet bound to any resource. */
|
||||
export function createAttachment(id: string, mime: string): void {
|
||||
db.prepare(
|
||||
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
|
||||
).run(id, mime, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns attachment `attachmentId`'s MIME type.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such attachment.
|
||||
*/
|
||||
export function getAttachment(attachmentId: string): { mime: string } {
|
||||
const row = db.prepare(
|
||||
"SELECT mime FROM attachments WHERE id = ?",
|
||||
@@ -24,12 +38,12 @@ export function getAttachment(attachmentId: string): { mime: string } {
|
||||
return { mime: row.mime };
|
||||
}
|
||||
|
||||
// UUID pattern used to extract attachment IDs from markdown/text bodies.
|
||||
/** An attachment URL in a Markdown body, capturing its id. */
|
||||
const ATTACHMENT_RE =
|
||||
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
|
||||
|
||||
/**
|
||||
* Parse `text` for attachment URLs and bind any unowned attachments to
|
||||
* Binds the unowned attachments whose URLs appear in `text` to
|
||||
* `resourceId`. Only updates rows where resource_id IS NULL so a user cannot
|
||||
* claim attachments that already belong to another resource.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Backlinks between dumps: which dumps a dump's description and comments link
|
||||
* to, and the dumps related to one through them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { db, transaction } from "../db/connection.ts";
|
||||
import {
|
||||
@@ -7,17 +14,22 @@ import {
|
||||
} from "../model/dump.ts";
|
||||
import { UUID_RE } from "../lib/slugify.ts";
|
||||
|
||||
// Matches http(s) URLs in free text — stops at whitespace or markdown-link
|
||||
// delimiters (so `[text](url)` and `<url>` don't swallow the closing char).
|
||||
/**
|
||||
* An http(s) URL in free text — stops at whitespace or markdown-link
|
||||
* delimiters (so `[text](url)` and `<url>` don't swallow the closing char).
|
||||
*/
|
||||
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
|
||||
|
||||
// A link to another dump's page on this same site (e.g. pasted from the
|
||||
// address bar) — host-agnostic, since the configured public URL can differ
|
||||
// between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`,
|
||||
// ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
|
||||
// still resolve to the same target dump).
|
||||
/**
|
||||
* A link to another dump's page on this same site (e.g. pasted from
|
||||
* the address bar) — host-agnostic, since the configured public URL can differ
|
||||
* between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`,
|
||||
* ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
|
||||
* still resolve to the same target dump).
|
||||
*/
|
||||
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/;
|
||||
|
||||
/** Returns `url` as the URL parser normalises it, or null when it isn't one. */
|
||||
function canonicalize(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).toString();
|
||||
@@ -35,7 +47,7 @@ function resolveDumpPathId(idOrSlug: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the backlinks contributed by one text source (a dump's own
|
||||
* Re-derives the backlinks contributed by one text source (a dump's own
|
||||
* `comment` field, or a single comment's `body`) and persist them. Always
|
||||
* deletes the source's previous contribution first, so editing/clearing the
|
||||
* text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`,
|
||||
@@ -93,7 +105,10 @@ export function relinkBacklinks(
|
||||
});
|
||||
}
|
||||
|
||||
/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */
|
||||
/**
|
||||
* Returns the dumps linked to or from `dumpId`, newest first and at most 20,
|
||||
* without the private ones the requesting user can't see.
|
||||
*/
|
||||
export function getRelatedDumps(
|
||||
dumpId: string,
|
||||
requestingUserId?: string,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Categories: managing them, and which dumps belong to which.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
@@ -17,6 +23,7 @@ import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
|
||||
/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */
|
||||
function validateSlug(slug: string): void {
|
||||
if (!isValidSlugFormat(slug)) {
|
||||
throw new APIException(
|
||||
@@ -41,6 +48,7 @@ function validateSlug(slug: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */
|
||||
function requireName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
@@ -56,7 +64,10 @@ function requireName(name: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
|
||||
/**
|
||||
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending
|
||||
* field, and anything else as is.
|
||||
*/
|
||||
function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("categories.name")) {
|
||||
@@ -76,6 +87,11 @@ function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
throw err as Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns category `id`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function getCategoryById(id: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
@@ -84,6 +100,11 @@ export function getCategoryById(id: string): Category {
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function getCategoryBySlug(slug: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
@@ -92,7 +113,7 @@ export function getCategoryBySlug(slug: string): Category {
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/** Resolve a slug to its category id, or undefined if no such category. */
|
||||
/** Returns the id of the category at `slug`, or undefined when there is none. */
|
||||
export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
|
||||
return row && typeof (row as { id?: unknown }).id === "string"
|
||||
@@ -100,6 +121,7 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns every category, by position then slug. */
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
@@ -107,6 +129,12 @@ export function listCategories(): Category[] {
|
||||
return rows.filter(isCategoryRow).map(categoryRowToApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a category.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateSlug(req.slug);
|
||||
const name = requireName(req.name);
|
||||
@@ -137,6 +165,12 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `req` to category `id`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category, 400 when the new
|
||||
* slug or name is invalid, 409 when either is taken.
|
||||
*/
|
||||
export function updateCategory(
|
||||
id: string,
|
||||
req: UpdateCategoryRequest,
|
||||
@@ -176,6 +210,11 @@ export function updateCategory(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`; its dumps stay, outside it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function deleteCategory(id: string): void {
|
||||
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
|
||||
if (result.changes === 0) {
|
||||
@@ -184,9 +223,11 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 400 unless every id names an existing category. Callers that write
|
||||
* Checks that every id names an existing category. Callers that write
|
||||
* anything else first (a dump row, an uploaded file) run this before those
|
||||
* writes, so a bad id can't leave them half-done.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
const unique = [...new Set(categoryIds)];
|
||||
@@ -207,8 +248,10 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of categories a dump belongs to. Unknown category ids are
|
||||
* rejected. An empty list clears all category assignments.
|
||||
* Replaces the set of categories a dump belongs to. An empty list clears all
|
||||
* category assignments.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
@@ -228,7 +271,7 @@ export function setDumpCategories(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
||||
/** Fills `categoryIds` on each dump with a single batched lookup. */
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
@@ -248,6 +291,12 @@ export function attachCategoryIds(dumps: Dump[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, with
|
||||
* the requesting user's private ones.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function listDumpsByCategory(
|
||||
slug: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The site-wide chat: messages, their history, and each user's read marker.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
@@ -6,27 +12,36 @@ import { db } from "../db/connection.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
|
||||
/** How many messages a page holds when the caller names no limit. */
|
||||
const DEFAULT_LIMIT = 50;
|
||||
/** The most messages a page may hold. */
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
// Length cap for the replied-to preview snippet, so a reply to a long message
|
||||
// doesn't ship the whole 2000-char body over the wire for every reply. This is
|
||||
// raw markdown: the client strips it to text before showing a line of it, so
|
||||
// it's cut generously enough that an image URL or link doesn't eat it all.
|
||||
/**
|
||||
* The length cap of the replied-to preview snippet, so a reply to a long
|
||||
* message doesn't ship the whole 2000-char body over the wire for every reply.
|
||||
* This is raw markdown: the client strips it to text before showing a line of
|
||||
* it, so it's cut generously enough that an image URL or link doesn't eat it
|
||||
* all.
|
||||
*/
|
||||
const REPLY_SNIPPET_LEN = 300;
|
||||
|
||||
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
|
||||
const SELECT_COLS =
|
||||
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
|
||||
u.username as author_username, u.avatar_mime as author_avatar_mime,
|
||||
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
|
||||
|
||||
// Common joins: author (required) plus the optional replied-to message and its
|
||||
// author (LEFT — null when there's no reply or the target was deleted).
|
||||
/**
|
||||
* The common joins: author (required) plus the optional replied-to message and
|
||||
* its author (LEFT — null when there's no reply or the target was deleted).
|
||||
*/
|
||||
const FROM_JOINS = `FROM chat_messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id`;
|
||||
|
||||
/** Returns message `id`, or throws a 404. */
|
||||
function fetchMessage(id: string): ChatMessage {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
|
||||
@@ -37,6 +52,13 @@ function fetchMessage(id: string): ChatMessage {
|
||||
return chatMessageRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a message from `userId`, replying to `replyToId` when that message
|
||||
* exists, and binds its attachments.
|
||||
*
|
||||
* @throws {APIException} 400 when the body is blank or longer than
|
||||
* `VALIDATION.CHAT_MESSAGE_MAX`.
|
||||
*/
|
||||
export function createChatMessage(
|
||||
userId: string,
|
||||
body: string,
|
||||
@@ -77,6 +99,7 @@ export function createChatMessage(
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
/** Returns the author of message `id`, or throws a 404. */
|
||||
function getMessageOwner(id: string): string {
|
||||
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
|
||||
id,
|
||||
@@ -87,7 +110,14 @@ function getMessageOwner(id: string): string {
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// Owners may edit their own messages; moderators may edit any.
|
||||
/**
|
||||
* Replaces a message's body, as its author or a moderator (`canModerate`), and
|
||||
* binds any new attachment.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such message, 401 when the user
|
||||
* is neither its author nor a moderator, 400 when the body is blank or too
|
||||
* long.
|
||||
*/
|
||||
export function updateChatMessage(
|
||||
id: string,
|
||||
body: string,
|
||||
@@ -124,7 +154,12 @@ export function updateChatMessage(
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
// Owners may delete their own messages; moderators may delete any.
|
||||
/**
|
||||
* Deletes a message, as its author or a moderator (`canModerate`).
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such message, 401 when the user
|
||||
* is neither its author nor a moderator.
|
||||
*/
|
||||
export function deleteChatMessage(
|
||||
id: string,
|
||||
requestingUserId: string,
|
||||
@@ -141,16 +176,22 @@ export function deleteChatMessage(
|
||||
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
||||
}
|
||||
|
||||
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
|
||||
/**
|
||||
* A position in the chat stream. Messages are ordered by (createdAt, id), so
|
||||
* two sharing a millisecond still have a strict order and a page boundary
|
||||
* between them can't skip either. */
|
||||
* between them can't skip either.
|
||||
*/
|
||||
export interface ChatCursor {
|
||||
/** The `created_at` of the message the cursor sits at. */
|
||||
createdAt: string;
|
||||
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
|
||||
* every message sharing that timestamp. */
|
||||
/**
|
||||
* The id of that message. Omitted: the cursor sits before (for `after`) or
|
||||
* after (for `before`) every message sharing that timestamp.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Converts query rows to messages. */
|
||||
function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
@@ -162,6 +203,7 @@ function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
return rows.map(chatMessageRowToApi);
|
||||
}
|
||||
|
||||
/** Clamps a page size to [1, {@link MAX_LIMIT}]. */
|
||||
function capLimit(limit: number): number {
|
||||
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
|
||||
}
|
||||
@@ -213,13 +255,20 @@ export function getChatMessagesAfter(
|
||||
return toMessages(rows);
|
||||
}
|
||||
|
||||
/** How far a user has read the chat. */
|
||||
export interface ChatReadState {
|
||||
/** created_at of the newest message the user has seen (ISO). */
|
||||
/** The `created_at` of the newest message the user has seen (ISO). */
|
||||
readAt: string;
|
||||
/** Messages from other people since then. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how far `userId` has read the chat; a user who never opened it has
|
||||
* read up to their registration.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
export function getChatReadState(userId: string): ChatReadState {
|
||||
const row = db.prepare(
|
||||
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
|
||||
@@ -238,6 +287,9 @@ export function getChatReadState(userId: string): ChatReadState {
|
||||
* Moves the user's read marker forward to `at` (a message's created_at) —
|
||||
* never backward, so a lagging tab can't undo a newer tab's read, and never
|
||||
* past the newest message, so a bogus timestamp can't pre-read the future.
|
||||
*
|
||||
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
|
||||
* such user.
|
||||
*/
|
||||
export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const parsed = new Date(at);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Likes on comments.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
|
||||
@@ -5,13 +11,18 @@ import { db } from "../db/connection.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||
|
||||
/** A comment's likes after a like was cast or removed. */
|
||||
export interface LikeResult {
|
||||
/** The comment's like count now. */
|
||||
likeCount: number;
|
||||
/** Likes on a private dump's comments go to the dump's owner only. */
|
||||
/** Whether the comment's dump is private: its likes go to the dump's owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
// Visibility of the comment's dump, or a 404 when the user can't see it.
|
||||
/**
|
||||
* Returns whether the comment's dump is private, or throws a 404 when the user
|
||||
* can't see it.
|
||||
*/
|
||||
function commentVisibility(commentId: string, userId: string): boolean {
|
||||
const row = db.prepare(
|
||||
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
|
||||
@@ -23,6 +34,12 @@ function commentVisibility(commentId: string, userId: string): boolean {
|
||||
return Boolean(row.is_private);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `userId`'s like on a comment and notifies its author.
|
||||
*
|
||||
* @throws {APIException} 404 when the comment doesn't exist or sits on someone
|
||||
* else's private dump, 409 when the user already likes it.
|
||||
*/
|
||||
export function castCommentLike(
|
||||
commentId: string,
|
||||
userId: string,
|
||||
@@ -62,8 +79,12 @@ export function castCommentLike(
|
||||
return { likeCount, isPrivate };
|
||||
}
|
||||
|
||||
// No visibility check: taking back a like is harmless, and it's the user's own
|
||||
// row.
|
||||
/**
|
||||
* Takes back `userId`'s like on a comment. No visibility check: taking back a
|
||||
* like is harmless, and it's the user's own row.
|
||||
*
|
||||
* @throws {APIException} 404 when the user doesn't like the comment.
|
||||
*/
|
||||
export function removeCommentLike(
|
||||
commentId: string,
|
||||
userId: string,
|
||||
@@ -97,6 +118,7 @@ export function removeCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the ids of every comment `userId` likes. */
|
||||
export function getUserCommentLikes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
|
||||
@@ -104,6 +126,7 @@ export function getUserCommentLikes(userId: string): string[] {
|
||||
return rows.map((r) => r.comment_id);
|
||||
}
|
||||
|
||||
/** Returns a page of the users who like a comment, latest first. */
|
||||
export function getCommentLikers(
|
||||
commentId: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Comments on dumps, threaded by `parent_id`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
@@ -11,10 +17,12 @@ import {
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
|
||||
/** The columns of a full `CommentRow`, from `comments c` and `users u`. */
|
||||
const SELECT_COLS =
|
||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
|
||||
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
||||
|
||||
/** Returns comment `commentId`, or throws a 404. */
|
||||
function fetchComment(commentId: string): Comment {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
|
||||
@@ -32,6 +40,11 @@ function fetchComment(commentId: string): Comment {
|
||||
return commentRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the dump comment `commentId` is posted under.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment.
|
||||
*/
|
||||
export function getCommentDumpId(commentId: string): string {
|
||||
const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get(
|
||||
commentId,
|
||||
@@ -42,6 +55,10 @@ export function getCommentDumpId(commentId: string): string {
|
||||
return row.dump_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every comment on dump `dumpId`, deleted ones included, oldest
|
||||
* first.
|
||||
*/
|
||||
export function getComments(dumpId: string): Comment[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
|
||||
@@ -57,6 +74,11 @@ export function getComments(dumpId: string): Comment[] {
|
||||
return rows.map(commentRowToApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment on dump `dumpId`, replying to `parentId` if given; notifies
|
||||
* the dump's poster and anyone @mentioned, and binds its attachments and
|
||||
* backlinks.
|
||||
*/
|
||||
export function createComment(
|
||||
dumpId: string,
|
||||
userId: string,
|
||||
@@ -86,6 +108,13 @@ export function createComment(
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a comment's body, as its author or a moderator (`canModerate`);
|
||||
* notifies anyone newly @mentioned, and rebinds its attachments and backlinks.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment, 400 when it is
|
||||
* deleted, 401 when the user is neither its author nor a moderator.
|
||||
*/
|
||||
export function updateComment(
|
||||
commentId: string,
|
||||
body: string,
|
||||
@@ -139,6 +168,14 @@ export function updateComment(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a comment, as its author or a moderator (`canModerate`): its body is
|
||||
* emptied and its backlinks dropped, but the row stays so its replies keep
|
||||
* their place.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment, 401 when the user
|
||||
* is neither its author nor a moderator.
|
||||
*/
|
||||
export function deleteComment(
|
||||
commentId: string,
|
||||
requestingUserId: string,
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Dumps: posting links and files, editing and deleting them, and reading them
|
||||
* as each viewer may see them. A private dump exists only for its poster.
|
||||
*
|
||||
* The functions that change a dump don't check who asks: the routes do, before
|
||||
* calling them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
@@ -49,13 +59,17 @@ import {
|
||||
setDumpCategories,
|
||||
} from "./category-service.ts";
|
||||
|
||||
/** Checks whether a file dump may have type `mime`. */
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
|
||||
// the multipart ones (file create and replace) arrive here unchecked.
|
||||
/**
|
||||
* Throws a 400 when a file dump's title or comment is too long. The JSON routes
|
||||
* validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart
|
||||
* ones (file create and replace) arrive here unchecked.
|
||||
*/
|
||||
function assertFileDumpText(title?: string, comment?: string): void {
|
||||
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||
throw new APIException(
|
||||
@@ -73,6 +87,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the title a link gets without a better one: its host. */
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -81,6 +96,13 @@ function titleFromUrl(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a link as `userId`, with the preview fetched from it; a public one is
|
||||
* announced and notified to the poster's followers.
|
||||
*
|
||||
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
|
||||
* category doesn't exist.
|
||||
*/
|
||||
export async function createUrlDump(
|
||||
request: CreateUrlDumpRequest,
|
||||
userId: string,
|
||||
@@ -148,6 +170,13 @@ export async function createUrlDump(
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
|
||||
* one is announced and notified to the poster's followers.
|
||||
*
|
||||
* @throws {APIException} 400 when the title or comment is too long, the file's
|
||||
* type isn't allowed, it exceeds 50 MB, or a category doesn't exist.
|
||||
*/
|
||||
export async function createFileDump(
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
@@ -239,7 +268,7 @@ export async function createFileDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* A file dump's version as the app puts it in file and thumbnail URLs
|
||||
* Returns a file dump's version as the app puts it in file and thumbnail URLs
|
||||
* (`?v=`): it changes whenever the file can have. Mirrored by
|
||||
* `dumpFileVersion` in src/utils/shared/urls.ts.
|
||||
*/
|
||||
@@ -251,8 +280,11 @@ export function dumpFileVersion(
|
||||
}`;
|
||||
}
|
||||
|
||||
/** Files generated from a dump's upload (see api/routes/thumbnails.ts):
|
||||
* dropped when it's replaced or gone, so they're made again from the new one. */
|
||||
/**
|
||||
* Removes the files generated from a dump's upload (see
|
||||
* api/routes/thumbnails.ts) when it's replaced or gone, so they're made again
|
||||
* from the new one.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
|
||||
@@ -261,7 +293,10 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||
/**
|
||||
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
|
||||
* check: use only when ownership is already enforced.
|
||||
*/
|
||||
function fetchDump(idOrSlug: string): Dump {
|
||||
const row = UUID_RE.test(idOrSlug)
|
||||
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||
@@ -274,7 +309,11 @@ function fetchDump(idOrSlug: string): Dump {
|
||||
return dumpRowToApi(row);
|
||||
}
|
||||
|
||||
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
|
||||
/**
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs.
|
||||
*/
|
||||
export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
const dump = fetchDump(dumpId);
|
||||
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||
@@ -284,6 +323,11 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps whose title, comment, or previewed title or
|
||||
* description contains `query`, newest first, and how many match in all: the
|
||||
* public ones, and the requesting user's own.
|
||||
*/
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
page: number,
|
||||
@@ -321,6 +365,10 @@ export function searchDumps(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps, newest first: the public ones, and the
|
||||
* requesting user's own.
|
||||
*/
|
||||
export function listDumps(
|
||||
page: number,
|
||||
limit: number,
|
||||
@@ -350,7 +398,8 @@ export function listDumps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps that already point at the same thing as `url`, newest first.
|
||||
* Returns the dumps that already point at the same thing as `url`, newest
|
||||
* first.
|
||||
*
|
||||
* Matched on the canonical key rather than the raw URL, so a link reposted with
|
||||
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
|
||||
@@ -397,6 +446,14 @@ export function findDumpsByUrl(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `request` to a dump: its title, comment, visibility and categories,
|
||||
* and a URL dump's link (fetching the new preview). Announces the change as the
|
||||
* visibility requires, and notifies anyone newly @mentioned.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL
|
||||
* or a category doesn't exist.
|
||||
*/
|
||||
export async function updateDump(
|
||||
dumpId: string,
|
||||
request: UpdateDumpRequest,
|
||||
@@ -538,14 +595,23 @@ export async function updateDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a file dump's upload, and with it its comment and optionally its
|
||||
* title, categories and visibility. `isPrivate` left out, visibility stays as
|
||||
* it is; it is taken here rather than by a second request so a new file and a
|
||||
* change of visibility land together. The old file is restored if the update
|
||||
* fails.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or
|
||||
* comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a
|
||||
* category doesn't exist.
|
||||
*/
|
||||
export async function replaceFileDump(
|
||||
dumpId: string,
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
title?: string,
|
||||
categoryIds?: string[],
|
||||
// Left out, visibility stays as it is. Taken here rather than by a second
|
||||
// request so a new file and a change of visibility land together.
|
||||
isPrivate?: boolean,
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
@@ -651,6 +717,10 @@ export async function replaceFileDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s dumps, newest first, the private ones only with
|
||||
* `includePrivate`.
|
||||
*/
|
||||
export function getDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -674,6 +744,10 @@ export function getDumpsByUser(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
|
||||
* private ones only when they are the one asking.
|
||||
*/
|
||||
export function getVotedDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -715,6 +789,12 @@ export function getVotedDumpsByUser(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL dump's preview again and replaces its title and preview.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the
|
||||
* page can't be reached.
|
||||
*/
|
||||
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
@@ -754,6 +834,11 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export function getDumpThumbnailInfo(
|
||||
idOrSlug: string,
|
||||
): { id: string; mime: string } | undefined {
|
||||
@@ -763,6 +848,11 @@ export function getDumpThumbnailInfo(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dump's new custom thumbnail, once its file is in place.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run(
|
||||
@@ -774,6 +864,11 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dump's custom thumbnail, and its file.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
||||
@@ -784,6 +879,11 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a dump, its files and thumbnails, and announces it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export async function deleteDump(dumpId: string): Promise<void> {
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
/**
|
||||
* Sending mail through the SMTP server in {@link SMTPS_URL}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
|
||||
|
||||
import { SMTPS_URL } from "../config.ts";
|
||||
|
||||
/** One address, a comma-separated list of them, or an array. */
|
||||
export type EmailRecipient = string | string[];
|
||||
|
||||
/** A message to send. It needs a recipient, a subject, and a text or HTML body. */
|
||||
export interface EmailMessage {
|
||||
/** The sender's address. */
|
||||
from: string;
|
||||
/** The recipients. */
|
||||
to: EmailRecipient;
|
||||
/** The subject line. */
|
||||
subject: string;
|
||||
/** The plain-text body. */
|
||||
text?: string;
|
||||
/** The HTML body. */
|
||||
html?: string;
|
||||
/** The carbon-copy recipients. */
|
||||
cc?: EmailRecipient;
|
||||
/** The blind-carbon-copy recipients. */
|
||||
bcc?: EmailRecipient;
|
||||
/** The address replies go to. */
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/** What the SMTP server said about a sent message. */
|
||||
export interface EmailSendResult {
|
||||
/** The message's `Message-ID`. */
|
||||
messageId: string;
|
||||
/** The recipients the server accepted. */
|
||||
accepted: string[];
|
||||
/** The recipients the server rejected. */
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
/** Checks whether an SMTP server is configured. */
|
||||
export function isEmailEnabled(): boolean {
|
||||
return SMTPS_URL.length > 0;
|
||||
}
|
||||
|
||||
/** Flattens the recipient forms nodemailer takes and returns into addresses. */
|
||||
function normalizeRecipients(
|
||||
recipient: string | readonly (string | { address?: string })[] | undefined,
|
||||
): string[] {
|
||||
@@ -53,12 +75,24 @@ function getTransporter(): Transporter {
|
||||
return transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the connection to the SMTP server. Resolves false when email is
|
||||
* disabled.
|
||||
*
|
||||
* @throws {Error} When the server can't be reached or refuses the login.
|
||||
*/
|
||||
export async function verifyEmailTransport(): Promise<boolean> {
|
||||
if (!isEmailEnabled()) return false;
|
||||
await getTransporter().verify();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `message`.
|
||||
*
|
||||
* @throws {Error} When email is disabled, the message lacks a recipient,
|
||||
* subject or body, or the server refuses it.
|
||||
*/
|
||||
export async function sendEmail(
|
||||
message: EmailMessage,
|
||||
): Promise<EmailSendResult> {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Following users and playlists, and the feeds made of what one follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
@@ -21,6 +27,12 @@ import { attachCategoryIds } from "./category-service.ts";
|
||||
|
||||
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Makes `followerId` follow `followedUserId`, and notifies them the first
|
||||
* time. Following again changes nothing.
|
||||
*
|
||||
* @throws {APIException} 400 when a user tries to follow themselves.
|
||||
*/
|
||||
export function followUser(followerId: string, followedUserId: string): void {
|
||||
if (followerId === followedUserId) {
|
||||
throw new APIException(
|
||||
@@ -51,6 +63,7 @@ export function followUser(followerId: string, followedUserId: string): void {
|
||||
if (isNew) notifyUserNewFollower(followerId, followedUserId);
|
||||
}
|
||||
|
||||
/** Makes `followerId` stop following `followedUserId`, if they did. */
|
||||
export function unfollowUser(followerId: string, followedUserId: string): void {
|
||||
db.prepare(
|
||||
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
|
||||
@@ -59,6 +72,13 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
|
||||
|
||||
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Makes `followerId` follow a public playlist, and notifies its owner the
|
||||
* first time. Following again changes nothing.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when it is
|
||||
* private.
|
||||
*/
|
||||
export function followPlaylist(followerId: string, playlistId: string): void {
|
||||
const row = db.prepare(
|
||||
`SELECT id, is_public FROM playlists WHERE id = ?;`,
|
||||
@@ -97,6 +117,7 @@ export function followPlaylist(followerId: string, playlistId: string): void {
|
||||
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
|
||||
}
|
||||
|
||||
/** Makes `followerId` stop following a playlist, if they did. */
|
||||
export function unfollowPlaylist(followerId: string, playlistId: string): void {
|
||||
db.prepare(
|
||||
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
|
||||
@@ -105,6 +126,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
|
||||
|
||||
// ── Follow status ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the ids of every user and playlist `followerId` follows. */
|
||||
export function getFollowStatus(followerId: string): FollowStatus {
|
||||
const rawUserRows = db.prepare(
|
||||
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||
@@ -132,6 +154,10 @@ export function getFollowStatus(followerId: string): FollowStatus {
|
||||
|
||||
// ── Followed-users feed ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a page of the public dumps posted by the users `followerId` follows,
|
||||
* newest first, optionally only those in `categoryId`.
|
||||
*/
|
||||
export function getFollowedUsersDumpFeed(
|
||||
followerId: string,
|
||||
page: number,
|
||||
@@ -172,6 +198,10 @@ export function getFollowedUsersDumpFeed(
|
||||
|
||||
// ── Followed-playlists dump feed ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a page of the public dumps in the public playlists `followerId`
|
||||
* follows, most recently added first, optionally only those in `categoryId`.
|
||||
*/
|
||||
export function getFollowedPlaylistsDumpFeed(
|
||||
followerId: string,
|
||||
page: number,
|
||||
@@ -214,6 +244,10 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
|
||||
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a page of the public playlists `userId` follows, most recently
|
||||
* followed first.
|
||||
*/
|
||||
export function getFollowedPlaylistsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -245,6 +279,7 @@ export function getFollowedPlaylistsByUser(
|
||||
|
||||
// ── Followed users (as user objects) ─────────────────────────────────────────
|
||||
|
||||
/** Returns a page of the users `userId` follows, most recently followed first. */
|
||||
export function getFollowedUsersByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Invites: making them, and checking and claiming one on registration.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
@@ -5,11 +11,15 @@ import { db } from "../db/connection.ts";
|
||||
import { isInviteRow } from "../model/invite.ts";
|
||||
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
||||
|
||||
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
|
||||
/**
|
||||
* Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22
|
||||
* chars).
|
||||
*/
|
||||
function generateInviteToken(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
/** Makes an invite from `inviterId` and returns its token. */
|
||||
export function createInvite(inviterId: string): string {
|
||||
const token = generateInviteToken();
|
||||
db.prepare(
|
||||
@@ -20,12 +30,14 @@ export function createInvite(inviterId: string): string {
|
||||
|
||||
/**
|
||||
* Looks the token up, checks it has not been used and has not expired (same
|
||||
* 7-day window as the startup cleanup). Returns the inviterId on success;
|
||||
* throws APIException otherwise.
|
||||
* 7-day window as the startup cleanup), and returns the inviter's id.
|
||||
*
|
||||
* Tokens are opaque random strings stored as the row's primary key. Invites
|
||||
* created before this scheme (full JWTs) are still stored verbatim in the
|
||||
* token column, so they continue to validate by direct lookup.
|
||||
*
|
||||
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
||||
* 409 when it has been used.
|
||||
*/
|
||||
export function validateInvite(token: string): string {
|
||||
const row = db.prepare(
|
||||
@@ -62,9 +74,13 @@ export function validateInvite(token: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the token and marks it used in one step, returning the inviterId.
|
||||
* Validates the token and marks it used in one step, returning the inviter's
|
||||
* id.
|
||||
* Claim before the (async) account creation, not after: checking first and
|
||||
* marking later lets parallel registrations all pass the check on one invite.
|
||||
*
|
||||
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
||||
* 409 when it has been used.
|
||||
*/
|
||||
export function claimInvite(token: string): string {
|
||||
const inviterId = validateInvite(token);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Notifications: storing them, pushing each new one to its user's sockets,
|
||||
* and the triggers that create them. A trigger passes a source key, so the same
|
||||
* event never notifies a user twice.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
Notification,
|
||||
NotificationData,
|
||||
@@ -17,7 +25,10 @@ import { MENTION_RE } from "../../shared/mention.ts";
|
||||
|
||||
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
|
||||
/**
|
||||
* Stores a notification for `userId` and pushes it live. With a `sourceKey`,
|
||||
* a second one for the same `(userId, sourceKey)` is a no-op.
|
||||
*/
|
||||
function createNotification(
|
||||
userId: string,
|
||||
type: NotificationType,
|
||||
@@ -61,6 +72,7 @@ function createNotification(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a page of `userId`'s notifications, newest first. */
|
||||
export function getNotificationsForUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -81,6 +93,7 @@ export function getNotificationsForUser(
|
||||
return pageOf(rawRows.map(notificationRowToApi), limit);
|
||||
}
|
||||
|
||||
/** Returns how many of `userId`'s notifications are unread. */
|
||||
export function getUnreadCount(userId: string): number {
|
||||
const row = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
|
||||
@@ -88,12 +101,14 @@ export function getUnreadCount(userId: string): number {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/** Marks every notification of `userId` read. */
|
||||
export function markAllRead(userId: string): void {
|
||||
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks one of `userId`'s notifications read; someone else's is left alone. */
|
||||
export function markOneRead(notificationId: string, userId: string): void {
|
||||
db.prepare(
|
||||
`UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`,
|
||||
@@ -102,6 +117,7 @@ export function markOneRead(notificationId: string, userId: string): void {
|
||||
|
||||
// ── Trigger helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Notifies `followedUserId` that `followerId` follows them. */
|
||||
export function notifyUserNewFollower(
|
||||
followerId: string,
|
||||
followedUserId: string,
|
||||
@@ -120,6 +136,10 @@ export function notifyUserNewFollower(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies a playlist's owner that `followerId` follows it, unless they are
|
||||
* the owner.
|
||||
*/
|
||||
export function notifyPlaylistOwnerNewFollower(
|
||||
followerId: string,
|
||||
playlistId: string,
|
||||
@@ -148,6 +168,7 @@ export function notifyPlaylistOwnerNewFollower(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following `dumperId` that they posted a dump. */
|
||||
export function notifyUserFollowersNewDump(
|
||||
dumperId: string,
|
||||
dumpId: string,
|
||||
@@ -215,6 +236,7 @@ export function notifyUserFollowersNewDump(
|
||||
});
|
||||
}
|
||||
|
||||
/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */
|
||||
export function notifyDumpOwnerUpvote(
|
||||
voterId: string,
|
||||
dumpId: string,
|
||||
@@ -243,6 +265,13 @@ export function notifyDumpOwnerUpvote(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies every user @mentioned in `body`, except its author, that they were
|
||||
* mentioned in the given comment, dump, playlist or chat message. Each is
|
||||
* notified once per mention context, however often the body is edited.
|
||||
* `skipUserId` spares a mentioned user (e.g. one already reading the chat
|
||||
* live, for whom the persistent notification would be redundant).
|
||||
*/
|
||||
export function notifyMentions(
|
||||
mentionerUserId: string,
|
||||
body: string,
|
||||
@@ -250,8 +279,6 @@ export function notifyMentions(
|
||||
contextId: string,
|
||||
contextTitle: string,
|
||||
dumpId?: string,
|
||||
// Optional: skip notifying a mentioned user (e.g. they're already reading the
|
||||
// chat live, so the persistent notification would be redundant).
|
||||
skipUserId?: (userId: string) => boolean,
|
||||
): void {
|
||||
const mentionerRow = db.prepare(
|
||||
@@ -288,6 +315,10 @@ export function notifyMentions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies a dump's poster that `commenterId` commented on it, unless they are
|
||||
* the poster.
|
||||
*/
|
||||
export function notifyDumpOwnerNewComment(
|
||||
commenterId: string,
|
||||
commentId: string,
|
||||
@@ -318,6 +349,10 @@ export function notifyDumpOwnerNewComment(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies a comment's author that `likerId` likes it, unless they are the
|
||||
* author.
|
||||
*/
|
||||
export function notifyCommentOwnerLike(
|
||||
likerId: string,
|
||||
commentId: string,
|
||||
@@ -350,6 +385,7 @@ export function notifyCommentOwnerLike(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following a playlist that a dump was added to it. */
|
||||
export function notifyPlaylistFollowersNewDump(
|
||||
playlistId: string,
|
||||
playlistTitle: string,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Password resets by email: sending a reset link, and setting the new
|
||||
* password with it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { db } from "../db/connection.ts";
|
||||
@@ -13,16 +20,17 @@ import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { marked } from "marked";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
/** How long a reset link works. */
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
|
||||
/** Per address: enough to retry a lost email, not enough to flood an inbox. */
|
||||
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
|
||||
|
||||
/**
|
||||
* Looks up the user by email, creates a signed reset token, persists it, and
|
||||
* sends the reset link. Always resolves without throwing so callers can return
|
||||
* 200 unconditionally (no user enumeration).
|
||||
*/
|
||||
// Per address: enough to retry a lost email, not enough to flood an inbox.
|
||||
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
|
||||
|
||||
@@ -60,6 +68,10 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
/**
|
||||
* Verifies the token (JWT signature + expiry + DB record + not used), updates
|
||||
* the password, and marks the token consumed.
|
||||
*
|
||||
* @throws {APIException} 400 when the password's length is out of bounds or
|
||||
* the token is invalid or expired, 404 when the token isn't on record or its
|
||||
* user is gone, 409 when it has been used.
|
||||
*/
|
||||
export async function resetPassword(
|
||||
token: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user