Compare commits
4 Commits
42e0356d21
...
80f3292c8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f3292c8f | ||
|
|
a36a4cac6b | ||
|
|
14e3ab125b | ||
|
|
0d4e344d77 |
114
README.md
114
README.md
@@ -1,6 +1,10 @@
|
||||
# gerbeur
|
||||
|
||||
A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes.
|
||||
A small invite-only social platform for sharing links and files. Users can post
|
||||
URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote,
|
||||
comment, follow each other, build playlists, and search content. A real-time
|
||||
WebSocket layer handles live presence, vote counts, and notifications. The UI is
|
||||
localized (English and French) and ships multiple visual themes.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -30,13 +34,16 @@ cp .env.example .env
|
||||
deno task dev
|
||||
```
|
||||
|
||||
This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently.
|
||||
This starts both the API server (port 8000, with file watching) and the Vite dev
|
||||
server (port 3000) concurrently.
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). On first run a default `admin` / `admin` account is created — change the password immediately.
|
||||
Open [http://localhost:3000](http://localhost:3000). On first run a default
|
||||
`admin` / `admin` account is created — change the password immediately.
|
||||
|
||||
### Environment variables
|
||||
|
||||
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
||||
See [`.env.example`](.env.example) for the full list with descriptions. Key
|
||||
variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
@@ -58,7 +65,10 @@ See [`.env.example`](.env.example) for the full list with descriptions. Key vari
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.
|
||||
The standard deployment runs API and frontend in a single container. The API
|
||||
server (Oak) serves the compiled frontend as static files, so both share the
|
||||
same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the
|
||||
externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.
|
||||
|
||||
```sh
|
||||
docker build -t gerbeur .
|
||||
@@ -77,12 +87,17 @@ docker run -d \
|
||||
```
|
||||
|
||||
The two volumes are required for persistence:
|
||||
- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized automatically on first run
|
||||
|
||||
- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized
|
||||
automatically on first run
|
||||
- `gerbeur-uploads` — user-uploaded files (`api/uploads/`)
|
||||
|
||||
#### Separate API and frontend (optional)
|
||||
|
||||
If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin HTTP/WebSocket requests are accepted:
|
||||
If you need to run the API on a different host than the frontend, pass the API
|
||||
location as build args so it gets baked into the frontend bundle. In that setup,
|
||||
add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin
|
||||
HTTP/WebSocket requests are accepted:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
@@ -94,7 +109,8 @@ docker build \
|
||||
|
||||
### Reverse proxy
|
||||
|
||||
Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile:
|
||||
Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS.
|
||||
Forward everything to port 8000. Example Caddyfile:
|
||||
|
||||
```
|
||||
example.com {
|
||||
@@ -119,10 +135,9 @@ api/
|
||||
validators/ # Per domain: request body guards
|
||||
lib/ # Helpers with no SQL and no outbound fetch: JWT, slugs, files, …
|
||||
db/
|
||||
connection.ts # The database handle and transaction()
|
||||
connection.ts # The database handle and transaction(); creates a new database
|
||||
startup.ts # Housekeeping run when the database opens
|
||||
schema.sql # Database schema
|
||||
init.ts # First-run database initialisation
|
||||
migrate.ts # Migration runner
|
||||
migrations/ # Versioned schema migrations
|
||||
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
|
||||
@@ -158,27 +173,65 @@ 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`.
|
||||
|
||||
All three parts — `api/`, `shared/` and `src/` — are 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…", "Renders…" for a component), 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
|
||||
module doc lists its endpoints, one line each: method and path, who may call it,
|
||||
what it does; a page's JSDoc names its route and who may see it. The first
|
||||
paragraph of a doc is its summary, which the reference shows alone in lists, so
|
||||
it is a sentence that stands on its own: it never ends in a colon introducing
|
||||
what follows.
|
||||
### Comments
|
||||
|
||||
A comment says what the code can't. If deleting it would lose nothing, it is the
|
||||
wrong comment: on an export, rewrite it; anywhere else, delete it.
|
||||
|
||||
**Every module and every export has a JSDoc** (`/** */`). Each gives at least
|
||||
one of:
|
||||
|
||||
1. **Context**: who uses it and where (a page and its route, a caller, a
|
||||
WebSocket message).
|
||||
2. **Usage**: what a caller must know to use it correctly, such as a
|
||||
precondition, an invariant, what it leaves to the caller, or a side effect.
|
||||
Add an example only when the call isn't obvious.
|
||||
3. **Place in the chain**: where its input comes from and where its output goes.
|
||||
|
||||
A type's doc is phrased from the value's side, naming the producer and the
|
||||
consumer: "Returned to Y by X when Z", "Passed to Y by X". It is never "What X
|
||||
returns to Y" or "What X passes to Y".
|
||||
|
||||
Don't pack a doc into a label followed by a colon and a list ("X: one thing,
|
||||
plus some other thing"). Write sentences with a subject and a verb.
|
||||
|
||||
When the obvious comment is useless, write the useful one: the context is always
|
||||
there. A **field** (of an interface, or a parameter) gets a doc only when it
|
||||
adds something its parent's doc doesn't. Context that concerns the whole type
|
||||
goes on the type, not on one of its fields. The same goes for private functions
|
||||
and locals. None of these count as a doc:
|
||||
|
||||
- a restatement of the name or type (`/** The dump. */`, "Whether it is
|
||||
loading.", "The props of X.", "Renders the X.");
|
||||
- a description of what the body, the JSX or the SQL plainly does;
|
||||
- a list that goes stale (every caller of a widely used component): name the
|
||||
kind of caller instead.
|
||||
|
||||
When a comment is there, it is as short as it can be, often one sentence, and it
|
||||
is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't
|
||||
count. A module doc is a few lines at most. It opens with what the module does,
|
||||
then says who calls it and what it hands back, and it doesn't repeat what its
|
||||
exports' docs say. `@throws` stays on whatever throws to its caller, with the
|
||||
statuses of an `APIException` and when each happens. A router's module doc lists
|
||||
its endpoints (method, path, who may call it), since the code states them
|
||||
nowhere in one place. A summary never ends in a colon: the reference shows it
|
||||
alone.
|
||||
|
||||
**`//` comments** go wherever the code isn't obvious. They explain why, not
|
||||
what: a workaround, an ordering constraint, a surprising limitation, a security
|
||||
reason, an event handler that must not bubble.
|
||||
|
||||
**Every claim is checked against the code** (grep the callers). A wrong comment
|
||||
is worse than none.
|
||||
|
||||
### Reference
|
||||
|
||||
In the frontend, every exported function states its return type, so the
|
||||
reference can show it: a component returns `React.JSX.Element` (or `| null`), a
|
||||
hook returning several things returns a documented `Use<Name>Result` interface.
|
||||
A component that takes props takes an exported `<Name>Props` interface, each
|
||||
prop documented. React's types are used through the global `React` namespace
|
||||
rather than a default import of `react`, which `deno doc` can't follow.
|
||||
A component that takes props takes an exported `<Name>Props` interface. React's
|
||||
types are used through the global `React` namespace rather than a default import
|
||||
of `react`, which `deno doc` can't follow.
|
||||
|
||||
`deno task doc` builds the HTML reference into `docs/` from every module of
|
||||
`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`).
|
||||
@@ -187,9 +240,10 @@ repository's three parts and their layers in prose; `deno doc` builds the list
|
||||
of every module itself, grouped by path. The `docs` workflow publishes it on
|
||||
every push to `main` that touches them. `deno task doc:lint` checks the rules
|
||||
the linter can see, and that no summary ends in a colon, through `doc-lint.ts`.
|
||||
`deno doc` doesn't document npm packages, so it takes a type imported from one
|
||||
(react-hook-form's `FieldValues`, say) for a private one, and those references
|
||||
alone are let through.
|
||||
Two kinds of diagnostic are let through. A field without a doc passes, since a
|
||||
field gets one only when it adds to its type's. `deno doc` doesn't document npm
|
||||
packages, so it takes a type imported from one (react-hook-form's `FieldValues`,
|
||||
say) for a private one, and those references pass too.
|
||||
|
||||
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
|
||||
split into the same domain folders — `auth`, `category`, `chat`, `comment`,
|
||||
|
||||
137
api/config.ts
137
api/config.ts
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The server's settings: those read from `GERBEUR_*` environment variables,
|
||||
* with their defaults, and the fixed limits the backend applies.
|
||||
* Reads the server's settings from the `GERBEUR_*` environment variables, once
|
||||
* at import, and holds the fixed limits the backend applies. The deno tasks
|
||||
* load `.env` with `--env-file`, so changing a setting needs a restart.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,12 +9,13 @@
|
||||
/** 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.
|
||||
* The `smtps://` URL of the server email-service sends mail through
|
||||
* (`GERBEUR_SMTPS_URL`). When it is empty, the welcome and password reset
|
||||
* emails are silently skipped.
|
||||
*/
|
||||
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
|
||||
"";
|
||||
/** The sender address of outgoing mail (`GERBEUR_FROM_EMAIL`). */
|
||||
/** The `From` address of the welcome and password reset emails. */
|
||||
export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
|
||||
"";
|
||||
|
||||
@@ -24,83 +26,91 @@ Hi **{{username}}**,
|
||||
Your account has been created successfully. Welcome aboard!`;
|
||||
|
||||
/**
|
||||
* The Markdown body of the welcome email (`GERBEUR_WELCOME_EMAIL_BODY`), with
|
||||
* `{{site_name}}` and `{{username}}` placeholders.
|
||||
* The Markdown of the welcome email sent on registration
|
||||
* (`GERBEUR_WELCOME_EMAIL_BODY`). The users route replaces `{{site_name}}` and
|
||||
* `{{username}}` in it and sends it as both text and HTML.
|
||||
*/
|
||||
export const WELCOME_EMAIL_BODY: string =
|
||||
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() ||
|
||||
DEFAULT_WELCOME_EMAIL_BODY;
|
||||
/** The key tokens are signed with (`GERBEUR_JWT_SECRET`). Required. */
|
||||
/**
|
||||
* The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`).
|
||||
* The server refuses to start without it.
|
||||
*/
|
||||
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.
|
||||
* The interface the server binds to (`GERBEUR_LISTEN_HOST`). The default,
|
||||
* 0.0.0.0, lets Docker forward the port; 127.0.0.1 keeps 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.
|
||||
* The URL visitors reach the server at (`GERBEUR_PUBLIC_URL`), without a
|
||||
* trailing slash. Behind a reverse proxy it must be the outside URL: it is an
|
||||
* allowed origin, it tells links to this site's own pages from other links,
|
||||
* and it prefixes the image URLs in their previews.
|
||||
*/
|
||||
export const PUBLIC_URL: string =
|
||||
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
|
||||
`http://localhost:${PORT}`;
|
||||
/**
|
||||
* 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).
|
||||
* The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't
|
||||
* {@link PUBLIC_URL}, as with a separate Vite server, without a trailing slash.
|
||||
* It is an allowed origin, and password reset links and the Open Graph tags'
|
||||
* page URLs point to it.
|
||||
*/
|
||||
export const FRONTEND_URL: string =
|
||||
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL;
|
||||
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") ||
|
||||
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).
|
||||
* The SQLite database file (`GERBEUR_DB_PATH`). api/test/setup.ts points it at
|
||||
* a throwaway file before anything opens the database.
|
||||
*/
|
||||
export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") ||
|
||||
"api/sql/gerbeur.db";
|
||||
|
||||
// Upload/files
|
||||
/**
|
||||
* The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`), one
|
||||
* subdirectory per kind.
|
||||
* The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`). Each kind has
|
||||
* its subdirectory below, created by the first upload, and each file is named
|
||||
* after the id of what it belongs to.
|
||||
*/
|
||||
export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") ||
|
||||
"api/uploads";
|
||||
/** Where file dumps are stored. */
|
||||
/** Holds the file of each file dump. */
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
/** Where avatars are stored. */
|
||||
/** Holds each user's avatar. */
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
/** Where playlist cover images are stored. */
|
||||
/** Holds each playlist's cover. */
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
/** Where images pasted into chat, comments and descriptions are stored. */
|
||||
/**
|
||||
* Holds the images pasted into chat messages, comments, dump notes and
|
||||
* descriptions. The startup tasks purge those nothing links to.
|
||||
*/
|
||||
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
/** Where custom dump thumbnails are stored. */
|
||||
/**
|
||||
* Holds the thumbnails a poster uploads (`<id>-custom`) and those the
|
||||
* thumbnails route generates from file dumps.
|
||||
*/
|
||||
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
|
||||
|
||||
/** The largest image upload accepted: 5 MB. */
|
||||
/**
|
||||
* The largest image upload accepted, in bytes, for avatars, covers, thumbnails
|
||||
* and attachments. lib/upload.ts states it in megabytes in its error.
|
||||
*/
|
||||
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",
|
||||
]);
|
||||
|
||||
/** The MIME type prefixes a file dump may have. */
|
||||
/**
|
||||
* The MIME type prefixes a file dump may have. dump-service also accepts the
|
||||
* exact types of {@link DUMP_ALLOWED_MIME_TYPES}.
|
||||
*/
|
||||
export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [
|
||||
"text/",
|
||||
"image/",
|
||||
"video/",
|
||||
"audio/",
|
||||
];
|
||||
/** The exact MIME types a file dump may have, beyond the prefixes. */
|
||||
/** The documents and archives a file dump may be, beyond the prefixes. */
|
||||
export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
@@ -114,41 +124,44 @@ export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
|
||||
"application/vnd.ms-powerpoint",
|
||||
]);
|
||||
|
||||
// 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. */
|
||||
/**
|
||||
* How long an invite stays usable. invite-service refuses an older one, and
|
||||
* the startup tasks purge it.
|
||||
*/
|
||||
export const UNUSED_INVITES_RETENTION_DAYS = 7;
|
||||
|
||||
// SEO/OG
|
||||
/** The site's name (`GERBEUR_SITE_NAME`), in page titles, OG tags and mail. */
|
||||
/**
|
||||
* The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG
|
||||
* tags, the icons' manifest and the emails.
|
||||
*/
|
||||
export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") ||
|
||||
"gerbeur";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The emoji that serves as the site's logo (`GERBEUR_SITE_EMOJI`). The icons
|
||||
* script draws the favicons from it, and index.html carries it to the app's
|
||||
* header, so changing it needs a restart but no rebuild.
|
||||
*/
|
||||
export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* How the frontend plays Bandcamp. `"embed"` uses Bandcamp's iframe, which
|
||||
* can't autoplay. `"native"` resolves the page's streams through
|
||||
* `GET /api/bandcamp/tracks` and plays them in the app's player, which
|
||||
* autoplays and queues an album's tracks.
|
||||
*/
|
||||
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.
|
||||
* How the frontend plays Bandcamp (`GERBEUR_BANDCAMP_PLAYER`), passed to it in
|
||||
* index.html. Any value but `"native"` means `"embed"`, so a typo falls back to
|
||||
* the player that always works.
|
||||
*/
|
||||
export const BANDCAMP_PLAYER: BandcampPlayer =
|
||||
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
|
||||
@@ -156,21 +169,21 @@ export const BANDCAMP_PLAYER: BandcampPlayer =
|
||||
: "embed";
|
||||
|
||||
/**
|
||||
* The background color of generated icons and the manifest. Mirrors the
|
||||
* hard-coded theme-color in index.html.
|
||||
* The background of the generated Apple touch icon, the manifest's colors, and
|
||||
* the accent of previews of this site's own pages. index.html's `theme-color`
|
||||
* tags don't follow it.
|
||||
*/
|
||||
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`.
|
||||
* The origins that CORS in main.ts and the WebSocket route accept. They are
|
||||
* {@link PUBLIC_URL}, {@link FRONTEND_URL} and the comma-separated
|
||||
* `GERBEUR_ALLOWED_ORIGINS`.
|
||||
*/
|
||||
export const ALLOWED_ORIGINS: string[] = Array.from(
|
||||
new Set([
|
||||
PUBLIC_URL,
|
||||
// FRONTEND_URL is auto-included so the separate-frontend case only needs
|
||||
// GERBEUR_FRONTEND_URL — no need to repeat it in GERBEUR_ALLOWED_ORIGINS.
|
||||
FRONTEND_URL,
|
||||
...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean),
|
||||
]),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* The database handle. Importing this module opens the database at
|
||||
* {@link DB_PATH}, applies the pending migrations and runs the startup tasks.
|
||||
* Opens the database at {@link DB_PATH} when first imported, creating it from
|
||||
* schema.sql if the file doesn't exist, then applies the pending migrations
|
||||
* and runs the startup tasks before any importer runs. Only the services
|
||||
* import it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,12 +12,25 @@ import { DB_PATH } from "../config.ts";
|
||||
import { runMigrations } from "./migrate.ts";
|
||||
import { runStartupTasks } from "./startup.ts";
|
||||
|
||||
/** The one connection every service queries through. */
|
||||
const isNew = await Deno.stat(DB_PATH).then(() => false, () => true);
|
||||
|
||||
/**
|
||||
* The server's only connection. Its queries are synchronous, so each one
|
||||
* blocks the event loop while it runs.
|
||||
*/
|
||||
export const db: DatabaseSync = new DatabaseSync(DB_PATH);
|
||||
// schema.sql holds every migration's change, so on a new database each
|
||||
// migration below finds its work done and is only recorded.
|
||||
if (isNew) {
|
||||
db.exec(
|
||||
await Deno.readTextFile(new URL("./schema.sql", import.meta.url)),
|
||||
);
|
||||
}
|
||||
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a
|
||||
// commit no longer waits on an fsync (a crash can lose the last transactions,
|
||||
// never corrupt the file). busy_timeout covers the init/migration scripts and
|
||||
// the test harness opening the same file.
|
||||
// commit doesn't wait on an fsync: a power loss can lose the last commits but
|
||||
// never corrupts the file. busy_timeout makes another connection to the file
|
||||
// wait for the lock instead of failing. SQLite enforces foreign keys, and so
|
||||
// the schema's ON DELETE cascades, only when each connection asks.
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
@@ -26,9 +41,10 @@ runMigrations(db);
|
||||
await runStartupTasks(db);
|
||||
|
||||
/**
|
||||
* Runs `fn` inside one transaction: several writes commit together (and cost
|
||||
* one commit rather than one each), and a throw rolls them all back. Nested
|
||||
* calls join the outer transaction.
|
||||
* Runs `fn` in a transaction, so its writes commit together, in one commit
|
||||
* rather than one each, and a throw rolls them all back and rethrows. A call
|
||||
* inside another joins the outer transaction. `fn` must be synchronous: an
|
||||
* `await` would commit before the rest of it runs.
|
||||
*/
|
||||
export function transaction<T>(fn: () => T): T {
|
||||
if (db.isTransaction) return fn();
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
try {
|
||||
await Deno.stat(DB_PATH);
|
||||
console.log("Database already exists, skipping initialization.");
|
||||
} catch {
|
||||
console.log("Initializing database from schema...");
|
||||
const schema = Deno.readTextFileSync("api/db/schema.sql");
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(schema);
|
||||
db.close();
|
||||
console.log("Database initialized.");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Brings an existing database up to date with the migrations under
|
||||
* migrations/, recording each applied one in `schema_migrations`.
|
||||
* Brings the database up to date with the migrations under migrations/, and
|
||||
* records each one it applies in `schema_migrations`. connection.ts runs it
|
||||
* each time the server opens the database.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -22,6 +23,8 @@ import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonic
|
||||
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
|
||||
import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts";
|
||||
import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts";
|
||||
import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts";
|
||||
import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -29,10 +32,10 @@ interface Migration {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every migration, in the order they run. A new one goes at the end, along
|
||||
* with the same change to schema.sql, and a shipped one is never edited or
|
||||
* moved. A database connection.ts made from schema.sql records none of them,
|
||||
* so each `up` must find its change already made and leave it be.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||
@@ -54,14 +57,19 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0014_chat_read_state", up: up0014ChatReadState },
|
||||
{ name: "0015_token_revocation", up: up0015TokenRevocation },
|
||||
{ name: "0016_performance_indexes", up: up0016PerformanceIndexes },
|
||||
{ name: "0017_case_insensitive_users", up: up0017CaseInsensitiveUsers },
|
||||
{
|
||||
name: "0018_attachment_resource_type",
|
||||
up: up0018AttachmentResourceType,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies each migration `db` hasn't recorded yet, each in its own
|
||||
* Applies each migration `db` hasn't recorded yet, in order, each in its own
|
||||
* transaction.
|
||||
*
|
||||
* @throws {Error} When a migration fails; that migration is rolled back and
|
||||
* the later ones aren't run.
|
||||
* @throws {Error} When a migration fails. That one is rolled back, the later
|
||||
* ones don't run, and the server doesn't start.
|
||||
*/
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds likes on comments, as the `comment_likes` table and a `like_count` on
|
||||
* each comment that comment-like-service keeps in step, so that listing
|
||||
* comments doesn't count their likes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing comments start with no likes, and a
|
||||
* database made from schema.sql already has both.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds `dumps.custom_thumbnail_mime`, so a poster can upload a thumbnail that
|
||||
* replaces the fetched one. It holds the uploaded image's type, and NULL means
|
||||
* the dump has none.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing dumps keep their fetched thumbnails,
|
||||
* and a database made from schema.sql already has the column.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds `dump_backlinks`, which records the links from a dump's note or
|
||||
* comments to other dumps, so a dump page can list the dumps related to it.
|
||||
* backlink-service fills it, and the index on `dumps.url` lets it find the
|
||||
* dumps an external link was posted as.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. It doesn't scan the notes and comments already
|
||||
* posted, so their links are recorded only once they are edited.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
|
||||
source_type TEXT NOT NULL,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds `users.role`, which replaces the `is_admin` flag so that moderators can
|
||||
* exist between users and admins. 0005 then drops the flag.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Every user becomes a `user`, except those with
|
||||
* `is_admin` set, who become admins.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
@@ -20,8 +21,7 @@ export function up(db: DatabaseSync): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Backfill existing admins to role 'admin'. Guarded because the legacy
|
||||
// is_admin column is absent on fresh databases (and dropped by 0005).
|
||||
// schema.sql never had is_admin.
|
||||
if (userCols.some((c) => c.name === "is_admin")) {
|
||||
db.exec(
|
||||
`UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
* Drops `users.is_admin`, now that 0004 has copied it into `users.role`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`, after 0004. A database made from schema.sql
|
||||
* never had the column.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,16 +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.
|
||||
* Adds categories, which admins create and posters file their dumps under, as
|
||||
* the `categories` table and the `dump_categories` join table.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing dumps start in no category, and a
|
||||
* database made from schema.sql already has both tables.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds `password_reset_tokens`, which holds the single-use tokens that
|
||||
* password-reset-service emails to users who forgot their password.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. A database made from schema.sql already has the
|
||||
* table.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,15 +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.
|
||||
* Adds `chat_messages`, which holds the messages of the site-wide chat.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. A database made from schema.sql already has the
|
||||
* table.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
|
||||
@@ -1,18 +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.
|
||||
* Adds `chat_messages.reply_to_id`, so a message can reply to another. It has
|
||||
* no foreign key on purpose: when the replied-to message is deleted, the id
|
||||
* stays and chat-service's join reads null, so the chat shows "deleted
|
||||
* message" where the quote was.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing messages reply to nothing, and a
|
||||
* database made from schema.sql already has the column.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
/**
|
||||
* Backfills the `start` parameter on stored YouTube embed URLs.
|
||||
* Adds the `start` parameter to the YouTube embed URLs stored before the
|
||||
* YouTube provider carried it over. `rich_content.embedUrl` is computed when a
|
||||
* dump is posted, and used to drop the source URL's `t` (`?t=1265`,
|
||||
* `?t=1h2m3s`), so those videos played from 0:00.
|
||||
*
|
||||
* `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.
|
||||
* It reads the time from the stored source URL and fetches nothing. It keeps
|
||||
* its own copy of the parsing, so that it migrates every database alike should
|
||||
* the provider's change.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
|
||||
/**
|
||||
* Returns the seconds in `url`'s `t` or `start`, which may be plain seconds,
|
||||
* `90s`, `20m5s` or `1h2m3s`.
|
||||
*/
|
||||
function extractStartSeconds(url: string): number | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
@@ -38,7 +35,10 @@ function extractStartSeconds(url: string): number | null {
|
||||
Number(hms[3] ?? 0);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. It leaves alone an embed URL that already has a
|
||||
* `start`, and a row whose `rich_content` doesn't parse.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, url, rich_content FROM dumps
|
||||
@@ -55,7 +55,7 @@ export function up(db: DatabaseSync): void {
|
||||
try {
|
||||
rich = JSON.parse(row.rich_content);
|
||||
} catch {
|
||||
continue; // malformed payload — leave it untouched
|
||||
continue;
|
||||
}
|
||||
if (rich.type !== "youtube" || !rich.embedUrl) continue;
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
/**
|
||||
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||
* `faviconUrl` field.
|
||||
* Moves site icons out of `rich_content.thumbnailUrl` into the new
|
||||
* `faviconUrl`. The metadata fetch used to fall back on the page's icon, and
|
||||
* then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a
|
||||
* 404, that the UI stretched over a 128×72 box. It now stops at real artwork,
|
||||
* and a missing `thumbnailUrl` means none, which the frontend draws a
|
||||
* placeholder for. This gives the older rows that meaning.
|
||||
*
|
||||
* 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.
|
||||
* It judges the stored URL alone and fetches nothing, so it doesn't fill
|
||||
* `accentColor`. The frontend derives a hue from the hostname when it is
|
||||
* missing, and refreshing a dump's metadata fetches the real one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -23,12 +16,9 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
|
||||
* extra rules.
|
||||
* Checks whether a stored thumbnail URL is a site icon. It is loose on
|
||||
* purpose, since artwork taken for an icon still shows whole on a tinted field,
|
||||
* only not cropped to fill the box. 0012 fixes the rows it got wrong.
|
||||
*/
|
||||
function isIconUrl(raw: string): boolean {
|
||||
let pathname: string;
|
||||
@@ -41,7 +31,11 @@ function isIconUrl(raw: string): boolean {
|
||||
/\.(ico|svg)$/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. It leaves alone a row that already has a
|
||||
* `faviconUrl`, and one whose `rich_content` doesn't parse. 0012 undoes its
|
||||
* mistakes.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
@@ -58,7 +52,7 @@ export function up(db: DatabaseSync): void {
|
||||
try {
|
||||
rich = JSON.parse(row.rich_content);
|
||||
} catch {
|
||||
continue; // malformed payload — leave it untouched
|
||||
continue;
|
||||
}
|
||||
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
|
||||
if (!isIconUrl(rich.thumbnailUrl)) continue;
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* Repairs 0011, which was too eager about what counts as a site icon.
|
||||
* Repairs the rows 0011 got wrong. 0011 took any path containing "favicon",
|
||||
* or ending in `.svg`, for an icon, so artwork such as
|
||||
* `/wp-content/uploads/2022/04/mir-logo-favicon.png` lost its `thumbnailUrl`,
|
||||
* and its dump showed as a pull-quote instead of an image in the journal
|
||||
* mosaic.
|
||||
*
|
||||
* 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.
|
||||
* This rule judges the file name and its folder instead. It spares
|
||||
* `mir-logo-favicon.png` and catches `icon_SEARCH.png`, which 0011 missed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Applies 0011's rule, kept verbatim so its false positives can be
|
||||
* identified.
|
||||
*/
|
||||
/** 0011's rule, copied as it shipped, to tell which rows it moved. */
|
||||
function wasIconUrl0011(raw: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
@@ -37,8 +26,9 @@ function wasIconUrl0011(raw: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a URL names a site icon, judged on its filename and
|
||||
* directory.
|
||||
* Checks whether a URL is a site icon. It is one when the file ends in `.ico`,
|
||||
* sits in a `favicon(s)` or `icon(s)` folder, or is named `favicon`,
|
||||
* `apple-touch-icon` or `icon`, alone or followed by a digit, `-`, `_` or `.`.
|
||||
*/
|
||||
function isIconUrl(raw: string): boolean {
|
||||
let pathname: string;
|
||||
@@ -55,7 +45,12 @@ function isIconUrl(raw: string): boolean {
|
||||
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. It moves a `faviconUrl` back to `thumbnailUrl`
|
||||
* only when 0011's rule takes it for an icon and this one doesn't. That also
|
||||
* catches the ones a later fetch stored that this rule doesn't recognise, such
|
||||
* as a `logo.svg`. It then moves the icons 0011 missed.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
@@ -73,10 +68,9 @@ export function up(db: DatabaseSync): void {
|
||||
try {
|
||||
rich = JSON.parse(row.rich_content);
|
||||
} catch {
|
||||
continue; // malformed payload — leave it untouched
|
||||
continue;
|
||||
}
|
||||
|
||||
// Artwork 0011 mistook for an icon: put it back.
|
||||
if (
|
||||
rich.faviconUrl && !rich.thumbnailUrl &&
|
||||
wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl)
|
||||
@@ -90,7 +84,6 @@ export function up(db: DatabaseSync): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
// An icon 0011's rule didn't recognize: move it now.
|
||||
if (
|
||||
rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl)
|
||||
) {
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
/**
|
||||
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||
* check on the create form — and backfills it for every existing URL dump.
|
||||
* Adds `dumps.url_canonical`, the key the create form's "already dumped" hint
|
||||
* looks a URL up by, and fills it for every URL dump already posted.
|
||||
*
|
||||
* 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.
|
||||
* It fills the key from each stored URL and fetches nothing. Its helpers are a
|
||||
* copy of lib/canonical-url.ts as it stood when this shipped: importing the
|
||||
* live one would give two databases migrated at different times keys made by
|
||||
* different rules. A change to lib/canonical-url.ts therefore needs a new
|
||||
* migration that fills the keys again.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -98,7 +91,10 @@ function canonicalizeUrl(raw: string): string | null {
|
||||
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. It fills only the keys still NULL, and leaves
|
||||
* NULL a URL that isn't http(s).
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* 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.
|
||||
* Moves the chat's unread state to the server, so that it survives a reload
|
||||
* and agrees across tabs and devices. `users.chat_read_at` holds the time of
|
||||
* the newest message the user has seen.
|
||||
*
|
||||
* 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.
|
||||
* It also replaces the index on `created_at` with one on `(created_at, id)`,
|
||||
* which the history now pages on, so that two messages from the same
|
||||
* millisecond can't fall between two pages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing users start with every message read,
|
||||
* instead of the whole history unread. A user created later starts with a
|
||||
* NULL `chat_read_at`, which chat-service reads as the user's creation time.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds `users.tokens_valid_after`, so that changing a password ends every
|
||||
* session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a
|
||||
* token issued before that time instead.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing users start with NULL, which revokes no
|
||||
* token.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
* Adds indexes for the queries that scanned a whole table. The feeds page
|
||||
* dumps by `created_at`, and a user's dumps and upvotes by `(user_id,
|
||||
* created_at)`, which replace the indexes on `user_id` alone. Dumps and
|
||||
* playlists are looked up by slug. Each foreign key with ON DELETE gets an
|
||||
* index on its child column, which SQLite otherwise scans on every delete of
|
||||
* a parent row, and the followers of a user or playlist are read on every new
|
||||
* dump. Mentions look usernames up by `lower(username)`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
/**
|
||||
* Run once by `runMigrations`. A database made from schema.sql already has
|
||||
* these indexes.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_dumps_user;
|
||||
|
||||
36
api/db/migrations/0017_case_insensitive_users.ts
Normal file
36
api/db/migrations/0017_case_insensitive_users.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Makes usernames and emails unique regardless of case. Usernames were unique
|
||||
* only as typed, so "Bob" and "bob" could both sign up, and a mention, which
|
||||
* looks names up in lower case, then reached one of them at random. Emails
|
||||
* had no constraint at all: two sign-ups racing past the service's check could
|
||||
* share an address, and password resets are looked up by address.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. It refuses to run, naming the clashes, when two
|
||||
* accounts already share a name or an address in some case: which one keeps
|
||||
* it is for whoever runs the site to decide, by editing one of them.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
for (const column of ["username", "email"]) {
|
||||
const clashes = db.prepare(
|
||||
`SELECT lower(${column}) AS value FROM users GROUP BY lower(${column}) HAVING COUNT(*) > 1;`,
|
||||
).all() as { value: string }[];
|
||||
if (clashes.length > 0) {
|
||||
throw new Error(
|
||||
`Several accounts share a ${column} in different cases: ${
|
||||
clashes.map((c) => c.value).join(", ")
|
||||
}. Change all but one of each, then restart.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_users_username_lower;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_lower ON users(lower(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users(lower(email));
|
||||
`);
|
||||
}
|
||||
34
api/db/migrations/0018_attachment_resource_type.ts
Normal file
34
api/db/migrations/0018_attachment_resource_type.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Adds `attachments.resource_type`, the kind of thing an attachment's
|
||||
* `resource_id` names. Without it, an attachment couldn't tell whose it was:
|
||||
* one pasted into a private dump was served to anyone with its URL, and one
|
||||
* whose dump, comment or message was deleted was kept for good.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing bound attachments get the type of the
|
||||
* table that holds their resource; one whose resource is gone gets none, and
|
||||
* the startup purge deletes it. A database made from schema.sql already has
|
||||
* the column.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(attachments);`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (cols.some((c) => c.name === "resource_type")) return;
|
||||
db.exec(`
|
||||
ALTER TABLE attachments ADD COLUMN resource_type TEXT;
|
||||
UPDATE attachments SET resource_type = CASE
|
||||
WHEN resource_id IN (SELECT id FROM dumps) THEN 'dump'
|
||||
WHEN resource_id IN (SELECT id FROM comments) THEN 'comment'
|
||||
WHEN resource_id IN (SELECT id FROM playlists) THEN 'playlist'
|
||||
WHEN resource_id IN (SELECT id FROM chat_messages) THEN 'chat'
|
||||
WHEN resource_id IN (SELECT id FROM users) THEN 'user'
|
||||
END
|
||||
WHERE resource_id IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
@@ -175,13 +175,15 @@ CREATE TABLE invites (
|
||||
|
||||
CREATE INDEX idx_invites_inviter ON invites(inviter_id);
|
||||
CREATE INDEX idx_users_invited_by ON users(invited_by);
|
||||
CREATE INDEX idx_users_username_lower ON users(lower(username));
|
||||
CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));
|
||||
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email));
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_id TEXT,
|
||||
mime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
resource_type TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
|
||||
|
||||
@@ -1,57 +1,75 @@
|
||||
/**
|
||||
* Housekeeping run when the database is opened.
|
||||
* Deletes what has expired and seeds the first admin, each time connection.ts
|
||||
* opens the database.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
ATTACHMENTS_DIR,
|
||||
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
|
||||
UNUSED_INVITES_RETENTION_DAYS,
|
||||
} from "../config.ts";
|
||||
import { hashPassword } from "../lib/password.ts";
|
||||
|
||||
/** Returns the time `ms` ago as an ISO string, the format dates are stored in. */
|
||||
function isoAgo(ms: number): string {
|
||||
return new Date(Date.now() - ms).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Runs after the migrations, each time the server starts. It deletes used or
|
||||
* expired reset tokens, invites past their validity, and attachments nothing
|
||||
* links to or whose resource is gone. On an instance with no users, it creates the user `admin` with the
|
||||
* password `admin`, which whoever installs it must change.
|
||||
*/
|
||||
export async function runStartupTasks(db: DatabaseSync): Promise<void> {
|
||||
// Purge expired/used password reset tokens
|
||||
// Dates are stored as ISO strings and compared as text, so the cutoffs are
|
||||
// ISO strings too. SQLite's datetime() has a space where ISO has a `T`.
|
||||
db.prepare(
|
||||
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
|
||||
).run();
|
||||
`DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at IS NOT NULL;`,
|
||||
).run(isoAgo(0));
|
||||
|
||||
// Purge expired unused invites
|
||||
db.prepare(
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
|
||||
).run();
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < ?;`,
|
||||
).run(isoAgo(UNUSED_INVITES_RETENTION_DAYS * 86_400_000));
|
||||
|
||||
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
|
||||
// An image is uploaded as soon as it's pasted, and linked only when the text
|
||||
// it's pasted into is saved, so a fresh unlinked one is left alone. One whose
|
||||
// dump, comment, playlist, message or user is gone goes whatever its age.
|
||||
const attachmentCutoff = isoAgo(
|
||||
ORPHANED_ATTACHMENTS_RETENTION_HOURS * 3_600_000,
|
||||
);
|
||||
const ORPHANED = `
|
||||
(resource_id IS NULL AND created_at < ?)
|
||||
OR (resource_id IS NOT NULL AND NOT CASE resource_type
|
||||
WHEN 'dump' THEN EXISTS (SELECT 1 FROM dumps WHERE id = resource_id)
|
||||
WHEN 'comment' THEN EXISTS (SELECT 1 FROM comments WHERE id = resource_id AND deleted = 0)
|
||||
WHEN 'playlist' THEN EXISTS (SELECT 1 FROM playlists WHERE id = resource_id)
|
||||
WHEN 'chat' THEN EXISTS (SELECT 1 FROM chat_messages WHERE id = resource_id)
|
||||
WHEN 'user' THEN EXISTS (SELECT 1 FROM users WHERE id = resource_id)
|
||||
ELSE 0
|
||||
END)`;
|
||||
const orphanedAttachments = db.prepare(
|
||||
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
|
||||
).all() as { id: string }[];
|
||||
`SELECT id FROM attachments WHERE ${ORPHANED};`,
|
||||
).all(attachmentCutoff) as { id: string }[];
|
||||
if (orphanedAttachments.length > 0) {
|
||||
for (const { id } of orphanedAttachments) {
|
||||
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
||||
}
|
||||
db.prepare(
|
||||
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
|
||||
).run();
|
||||
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
|
||||
.run(attachmentCutoff);
|
||||
}
|
||||
|
||||
// Create default admin user if no users exist
|
||||
// Registration needs an invite, so the first user can only come from here.
|
||||
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
|
||||
count: number;
|
||||
};
|
||||
if (userCount.count === 0) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync("admin", salt, 64).toString("hex");
|
||||
const passwordHash = `${hash}.${salt}`;
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`,
|
||||
).run(crypto.randomUUID(), passwordHash);
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', ?, 'admin@localhost')`,
|
||||
).run(crypto.randomUUID(), await hashPassword("admin"), isoAgo(0));
|
||||
console.log(
|
||||
"Created default admin user (username: admin, password: admin)",
|
||||
);
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
/**
|
||||
* The exception that carries an HTTP error out of any layer below a route.
|
||||
* Defines the exception that carries an HTTP error from any layer up to the
|
||||
* error middleware.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
|
||||
/** Passed to an {@link APIException}; any other status is a type error. */
|
||||
export type APIStatus = 400 | 401 | 403 | 404 | 409 | 429 | 500 | 502;
|
||||
|
||||
const CODES: Record<APIStatus, APIErrorCode> = {
|
||||
400: APIErrorCode.BAD_REQUEST,
|
||||
401: APIErrorCode.UNAUTHORIZED,
|
||||
403: APIErrorCode.FORBIDDEN,
|
||||
404: APIErrorCode.NOT_FOUND,
|
||||
409: APIErrorCode.CONFLICT,
|
||||
429: APIErrorCode.RATE_LIMITED,
|
||||
500: APIErrorCode.SERVER_ERROR,
|
||||
502: APIErrorCode.BAD_GATEWAY,
|
||||
};
|
||||
|
||||
/**
|
||||
* An error meant for the client, thrown anywhere below a route; the error
|
||||
* middleware turns it into an `APIFailure` response.
|
||||
* Thrown by a route, a service or a remote fetch to answer the request with an
|
||||
* error. The error middleware sends it as an `APIFailure`, message included,
|
||||
* so the message must be fit for the user. Use 401 for a caller who isn't
|
||||
* signed in, 403 for one who is but may not, and 404 rather than 403 when the
|
||||
* caller mustn't learn that the thing exists.
|
||||
*/
|
||||
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;
|
||||
readonly status: APIStatus;
|
||||
|
||||
/** Creates an exception answering `status` with `code` and `message`. */
|
||||
constructor(code: APIErrorCode, status: number, message: string) {
|
||||
constructor(status: APIStatus, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.code = CODES[status];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* Authenticates the tokens a request carries: session tokens in the
|
||||
* `Authorization` header and media tokens in a media URL's query string.
|
||||
* Checks session and media tokens against the live user row, not just their
|
||||
* signature. The auth middleware, the WebSocket route, the media routes and the
|
||||
* routes open to anonymous callers all authenticate through it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
import type { Role } from "../../shared/user.ts";
|
||||
import { getSessionState } from "../services/user-service.ts";
|
||||
|
||||
/**
|
||||
* Checks a signature-verified token against the live user row: the user must
|
||||
* still exist, and the token must not predate their last password change
|
||||
* (`users.tokens_valid_after`). Returns the live role, or null to refuse.
|
||||
*
|
||||
* `iat` has one-second resolution, so a token issued in the same second as the
|
||||
* change survives it; one issued after it (the fresh one the change hands back)
|
||||
* always does.
|
||||
* Returns the live role of a verified token's user, or null when the user is
|
||||
* gone or the token predates their last password change
|
||||
* (`users.tokens_valid_after`). `iat` counts whole seconds, so a token issued
|
||||
* in the same second as the change survives it.
|
||||
*/
|
||||
function checkTokenOwner(
|
||||
userId: string,
|
||||
@@ -38,9 +35,10 @@ function checkTokenOwner(
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a session token end to end: signature, expiry, a user that still
|
||||
* exists and no password change since it was issued. The role comes from the
|
||||
* DB, not the token, since tokens last 7 days and roles change under them.
|
||||
* Returns the payload of a valid session token, or null. Besides the signature
|
||||
* and expiry, it refuses a token whose user is gone or which predates a
|
||||
* password change. The returned role comes from the database, not the token,
|
||||
* since a token lasts 7 days and a role can change under it.
|
||||
*/
|
||||
export async function authenticate(
|
||||
token: string,
|
||||
@@ -53,8 +51,9 @@ export async function authenticate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the `?token=` on a media URL to the viewer's user id. Takes a media
|
||||
* token, or a session token from a client that predates them.
|
||||
* Returns the user id behind the `?token=` of a file, thumbnail or playlist
|
||||
* cover URL, or null when it isn't valid. A session token is accepted too, for
|
||||
* clients from before media tokens.
|
||||
*/
|
||||
export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
const media = await verifyMediaToken(token);
|
||||
@@ -65,18 +64,13 @@ export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user id of an optional Bearer token, or null for an anonymous
|
||||
* request.
|
||||
* Returns the caller's user id on a route open to anonymous callers, or null
|
||||
* when the request has no token. A placeholder such as `Bearer undefined`
|
||||
* counts as no token. An invalid token throws instead of reading as anonymous,
|
||||
* so that a stale session gets a 401 the client signs out on, rather than a
|
||||
* misleading 404 on a private resource.
|
||||
*
|
||||
* Distinguishes *missing* credentials from *invalid* ones:
|
||||
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
||||
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
|
||||
* - A real token that fails verification (expired / bad signature / revoked) →
|
||||
* 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.
|
||||
* @throws {APIException} 401 when a token is present but invalid.
|
||||
*/
|
||||
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
@@ -87,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||
|
||||
const payload = await authenticate(token);
|
||||
if (!payload) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||
throw new APIException(401, "Invalid token");
|
||||
}
|
||||
return payload.userId;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
/**
|
||||
* 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.
|
||||
* Reduces a URL to a key shared by every link to the same page or video.
|
||||
* `dump-service` stores it in `dumps.url_canonical` when a
|
||||
* dump is posted or edited, and `GET /api/dumps/by-url` matches on it
|
||||
* to warn the create form of a repost. The key is never fetched or shown, so it
|
||||
* may be lossy: it forces `https`, drops `www.`, tracking parameters and
|
||||
* timestamps.
|
||||
*
|
||||
* Because it is only ever compared against other canonical forms, it may be
|
||||
* lossy in ways a real URL never could be — it forces `https`, drops `www.`,
|
||||
* and throws away share/tracking parameters and timestamps, so
|
||||
* `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse
|
||||
* to one key.
|
||||
*
|
||||
* 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.
|
||||
* Migration 0013 backfilled existing rows with a frozen copy of these rules.
|
||||
* Changing them needs a new backfill migration, or old rows keep old keys.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -42,7 +37,7 @@ function isTrackingParam(key: string): boolean {
|
||||
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
|
||||
}
|
||||
|
||||
/** YouTube's hosts, already stripped of a leading "www.". */
|
||||
/** YouTube's hosts, without their "www.". */
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
"youtube.com",
|
||||
"m.youtube.com",
|
||||
@@ -51,12 +46,23 @@ const YOUTUBE_HOSTS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns `u`'s host in lower case and without "www.", when it is one of
|
||||
* YouTube's (youtu.be aside), and null otherwise. The YouTube preview provider
|
||||
* uses it too, so both agree on what a YouTube link is.
|
||||
*/
|
||||
function youtubeVideoId(
|
||||
export function youtubeHost(u: URL): string | null {
|
||||
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||
return YOUTUBE_HOSTS.has(host) ? host : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of 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`), and null for any other URL. `host` is lower case, without
|
||||
* "www.". The canonical key drops the timestamp, since the same video linked
|
||||
* at 2:30 is still a repost; the YouTube provider reads it separately.
|
||||
*/
|
||||
export function youtubeVideoId(
|
||||
host: string,
|
||||
pathname: string,
|
||||
params: URLSearchParams,
|
||||
@@ -71,8 +77,8 @@ function youtubeVideoId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical form of `raw` (see the module doc), or null when it
|
||||
* isn't an http(s) URL.
|
||||
* Returns the canonical key of `raw`, or null when it isn't an http(s) URL. A
|
||||
* YouTube video or playlist becomes one fixed URL whatever shape it came in.
|
||||
*/
|
||||
export function canonicalizeUrl(raw: string): string | null {
|
||||
let u: URL;
|
||||
@@ -94,7 +100,7 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
: null;
|
||||
if (listId) return `https://youtube.com/playlist?list=${listId}`;
|
||||
|
||||
// A single trailing slash is never meaningful; "/" itself becomes "".
|
||||
// Trailing slashes are dropped; "/" itself becomes "".
|
||||
const path = u.pathname.replace(/\/+$/, "");
|
||||
|
||||
const params = [...u.searchParams.entries()]
|
||||
@@ -102,8 +108,8 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
// A bare "#section" anchor points into the same page, so it is dropped —
|
||||
// but "#/route" and "#!/route" address distinct pages of a hash-routed app.
|
||||
// A "#section" anchor points into the same page and is dropped, but "#/route"
|
||||
// and "#!/route" are distinct pages of a hash-routed app.
|
||||
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
|
||||
|
||||
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The built frontend's `index.html`, with its runtime placeholders filled: the
|
||||
* site name, emoji, icon version and Bandcamp player mode.
|
||||
* Fills the placeholders of the frontend's `index.html` with settings read at
|
||||
* runtime, so they change with a restart and no rebuild. The static and OG
|
||||
* middlewares serve what it returns.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,18 +9,20 @@
|
||||
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. */
|
||||
/**
|
||||
* Versions the icon URLs, so browsers fetch new icons when the emoji changes.
|
||||
*/
|
||||
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.
|
||||
* Caches each root's filled file until its mtime changes, as a rebuild under a
|
||||
* running server does, so a navigation costs a stat instead of a read.
|
||||
*/
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* Returns `${root}/index.html` with its placeholders filled.
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version and
|
||||
* Bandcamp player mode filled in.
|
||||
*
|
||||
* @throws {Error} When the file can't be read.
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Signed tokens and password hashes: session tokens, media tokens, password
|
||||
* reset tokens, and scrypt hashing.
|
||||
* Signs and verifies the server's three kinds of token (session, media and
|
||||
* password reset), all HS256 with the one key. Only a token's signature and
|
||||
* expiry are checked here: `lib/auth.ts` checks a session or media token
|
||||
* against its user.
|
||||
*
|
||||
* 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";
|
||||
|
||||
import {
|
||||
@@ -26,22 +26,26 @@ if (!JWT_SECRET) {
|
||||
}
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
// ── Password reset tokens ─────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a single-use password reset token for `userId`, valid for an hour. */
|
||||
/**
|
||||
* Signs a password reset token for `userId`, valid for an hour. The token
|
||||
* isn't single-use by itself: `requestPasswordReset` stores it, and
|
||||
* `resetPassword` marks it used.
|
||||
*/
|
||||
export async function createPasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
return await new SignJWT({ purpose: "password-reset", userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
// Two requests in the same second would otherwise sign the same token,
|
||||
// which is the primary key of `password_reset_tokens`.
|
||||
.setJti(crypto.randomUUID())
|
||||
.setExpirationTime("1h")
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password reset token and returns its payload, or null when it is
|
||||
* invalid, expired or not a reset token.
|
||||
* Returns the payload of a password reset token, or null when it is invalid,
|
||||
* expired or another kind of token. Whether it was used is the caller's check.
|
||||
*/
|
||||
export async function verifyPasswordResetToken(
|
||||
token: string,
|
||||
@@ -55,29 +59,29 @@ export async function verifyPasswordResetToken(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth tokens ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a session token carrying `payload`, valid for 7 days. */
|
||||
/**
|
||||
* Signs a session token carrying `payload`, valid for 7 days. The users routes
|
||||
* issue it on sign-up, sign-in and password change, and the client sends it as
|
||||
* a bearer token.
|
||||
*/
|
||||
export async function createJWT(
|
||||
payload: Omit<AuthPayload, "exp">,
|
||||
): Promise<string> {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
// `iat` is what a password change revokes against (users.tokens_valid_after).
|
||||
// A password change revokes tokens by comparing `iat` with
|
||||
// `users.tokens_valid_after`.
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
// ── Media tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Signs a media token for `userId`, valid for 7 days. Media elements can't send
|
||||
* an `Authorization` header, so the URLs of private files, thumbnails and
|
||||
* playlist covers carry this token as `?token=`, where it lands in logs and
|
||||
* browser history. It only reads media, so a leaked URL doesn't expose the
|
||||
* account.
|
||||
*/
|
||||
export async function createMediaToken(userId: string): Promise<string> {
|
||||
return await new SignJWT({ purpose: "media", userId })
|
||||
@@ -88,8 +92,8 @@ export async function createMediaToken(userId: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a media token and returns its user id and issue time, or null when
|
||||
* it is invalid, expired or not a media token.
|
||||
* Returns the user id and issue time of a media token, or null when it is
|
||||
* invalid, expired or another kind of token.
|
||||
*/
|
||||
export async function verifyMediaToken(
|
||||
token: string,
|
||||
@@ -106,8 +110,9 @@ 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).
|
||||
* Returns the payload of a session token whose signature and expiry are valid,
|
||||
* or null. Call `authenticate` (`lib/auth.ts`) instead, which also checks the
|
||||
* token against its user.
|
||||
*/
|
||||
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
try {
|
||||
@@ -123,39 +128,3 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
return 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");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(`${derivedKey.toString("hex")}.${salt}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Checks `password` against a hash made by {@link hashPassword}. */
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
storedHash: string,
|
||||
): Promise<boolean> {
|
||||
const [hash, salt] = storedHash.split(".");
|
||||
// A malformed stored hash can't match anything; scrypt would throw on the
|
||||
// missing salt instead of answering.
|
||||
if (!hash || !salt) return Promise.resolve(false);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const expected = Buffer.from(hash ?? "", "hex");
|
||||
resolve(
|
||||
expected.length === derivedKey.length &&
|
||||
timingSafeEqual(expected, derivedKey),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
24
api/lib/like.ts
Normal file
24
api/lib/like.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Builds the patterns of the search queries' `LIKE`, for the dump, playlist
|
||||
* and user searches. A query's `%` and `_` are matched as themselves rather
|
||||
* than as wildcards: usernames hold underscores, and "a_b" must not find
|
||||
* "axb".
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the pattern matching text that contains `query`, or that starts
|
||||
* with it when `where` is `"prefix"`. The SQL must read `LIKE ? ESCAPE '\'`,
|
||||
* which the {@link LIKE} fragment spells.
|
||||
*/
|
||||
export function likePattern(
|
||||
query: string,
|
||||
where: "anywhere" | "prefix" = "anywhere",
|
||||
): string {
|
||||
const escaped = query.replace(/[\\%_]/g, "\\$&");
|
||||
return where === "prefix" ? `${escaped}%` : `%${escaped}%`;
|
||||
}
|
||||
|
||||
/** Spells `LIKE ? ESCAPE '\'`, for a {@link likePattern} parameter. */
|
||||
export const LIKE = "LIKE ? ESCAPE '\\'";
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Page-based pagination: reading `page` and `limit` from a query string, and
|
||||
* cutting fetched rows into a page.
|
||||
* Reads the page a list route's query string asks for, and cuts the rows a
|
||||
* service fetched into that page. The routes send the page as
|
||||
* `PaginatedData`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,9 +9,9 @@
|
||||
import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Reads the `page` and `limit` query parameters of a list route. It never
|
||||
* fails: a missing or invalid `page` reads as 1, and `limit` is kept between 1
|
||||
* and {@link PAGINATION_MAX_LIMIT}.
|
||||
*/
|
||||
export function parsePagination(
|
||||
params: URLSearchParams,
|
||||
@@ -30,18 +31,19 @@ export function parsePagination(
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
/** One page of a list, and whether another follows. */
|
||||
/**
|
||||
* Returned to the routes by the services' list functions, and sent as
|
||||
* `PaginatedData`.
|
||||
*/
|
||||
export interface Page<T> {
|
||||
/** The page's items, in order. */
|
||||
items: T[];
|
||||
/** Whether another page follows this one. */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns rows fetched with `LIMIT limit + 1` into a page: the extra row, if it
|
||||
* came back, is dropped and says there's a next page. No COUNT(*) over the
|
||||
* whole list just to know that.
|
||||
* Turns rows fetched with `LIMIT limit + 1` into a page. The extra row, when it
|
||||
* comes back, is dropped and tells that another page follows, which spares a
|
||||
* COUNT(*) over the whole list.
|
||||
*/
|
||||
export function pageOf<T>(rows: T[], limit: number): Page<T> {
|
||||
return rows.length > limit
|
||||
|
||||
48
api/lib/password.ts
Normal file
48
api/lib/password.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Hashes and checks passwords, for sign-up, sign-in, password changes and the
|
||||
* admin that `db/startup.ts` seeds. It reads no config, so the startup tasks
|
||||
* can use it before the server's settings are checked.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/**
|
||||
* Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for
|
||||
* `users.password_hash`.
|
||||
*/
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(`${derivedKey.toString("hex")}.${salt}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Checks `password` against a hash made by {@link hashPassword}. */
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
storedHash: string,
|
||||
): Promise<boolean> {
|
||||
const [hash, salt] = storedHash.split(".");
|
||||
// scrypt would throw on a missing salt, and a malformed hash matches nothing.
|
||||
if (!hash || !salt) return Promise.resolve(false);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const expected = Buffer.from(hash ?? "", "hex");
|
||||
resolve(
|
||||
expected.length === derivedKey.length &&
|
||||
timingSafeEqual(expected, derivedKey),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Permission checks against an authenticated caller.
|
||||
* Checks an authenticated caller's permissions. Routes call it where the
|
||||
* answer depends on more than the permission, such as "the owner, or a
|
||||
* moderator"; `requirePermission` gates a whole route instead.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,7 +9,10 @@
|
||||
import { hasPermission, type Permission } from "../../shared/permissions.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
|
||||
/** Checks whether the caller's role grants `permission`. */
|
||||
/**
|
||||
* Checks whether the caller's role grants `permission`. The role is the live
|
||||
* one `authenticate` put in the payload, not the token's.
|
||||
*/
|
||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||
return hasPermission(payload.role, permission);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
/**
|
||||
* Which of a playlist's dumps a given viewer may see.
|
||||
* Decides which of a playlist's dumps a viewer may see. The playlist service
|
||||
* filters its listings with it, and the WebSocket service filters each
|
||||
* client's `playlist_dumps_updated` message the same way.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/** A playlist's dump, with what deciding who may see it takes. */
|
||||
/**
|
||||
* The fields of a playlist's dump that {@link isMemberVisibleTo} needs. A
|
||||
* `Dump` has them. For its broadcasts, the playlist service reads only these.
|
||||
*/
|
||||
export interface PlaylistMember {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** Whether the dump is private. */
|
||||
isPrivate: boolean;
|
||||
/** The id of the dump's poster. */
|
||||
/** The dump's poster, not the playlist's owner. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether `viewerId` may see `member` in `playlist`. A public dump is
|
||||
* visible to all. A private dump is visible only to the playlist's owner, and
|
||||
* only if they posted it: moderators don't see it, and neither does the owner
|
||||
* once someone else's dump in their playlist goes private. A null or undefined
|
||||
* `viewerId` is a signed-out viewer.
|
||||
*/
|
||||
export function isMemberVisibleTo(
|
||||
member: PlaylistMember,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Limits how often something may happen, with token buckets kept in memory,
|
||||
* which a restart empties. Logins, password reset requests and the chat's
|
||||
* typing messages use it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,15 +12,22 @@ interface Bucket {
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** A token bucket's shape. */
|
||||
/**
|
||||
* Passed to {@link takeToken} by each limited action, which declares its own
|
||||
* as a constant.
|
||||
*/
|
||||
export interface RateLimit {
|
||||
/** Burst size. */
|
||||
/** How many tokens a full bucket holds, and so the largest burst. */
|
||||
capacity: number;
|
||||
/** Tokens regained per second. */
|
||||
refill: number;
|
||||
}
|
||||
|
||||
/** Takes one token from `key`'s bucket, or returns false when it is empty. */
|
||||
/**
|
||||
* Takes one token from `key`'s bucket, or returns false when it is empty. The
|
||||
* caller picks the key, such as `typing:<userId>`, and keys by user or account
|
||||
* so that more tabs or parallel requests don't buy more tokens.
|
||||
*/
|
||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
||||
@@ -35,8 +42,8 @@ export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Idle buckets have refilled by now for every limit in use; drop them so the
|
||||
// map tracks only keys that have been active lately.
|
||||
// Every limit in use refills within the hour, so an older bucket is full and
|
||||
// dropping it changes nothing.
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 60 * 60_000;
|
||||
for (const [key, bucket] of buckets) {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* Checks which slugs a category may take. A category's feed lives at the URL
|
||||
* root (`/music`), so its slug must not shadow another top-level route. The
|
||||
* category service runs these checks when a category is created or renamed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top-level path segments that already have meaning in the SPA router (see
|
||||
* src/App.tsx), plus a few reserved for infrastructure.
|
||||
* The top-level path segments a category slug may not take. They are the SPA's
|
||||
* own routes (`src/App.tsx`); `api`, `ws`, `assets` and `emojibase`, which the
|
||||
* server answers before the SPA; and `manage`, a former route whose old links
|
||||
* may still be about. Keep it in step with the route table and the build's
|
||||
* top-level folders.
|
||||
*/
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
"~",
|
||||
@@ -21,16 +25,18 @@ export const RESERVED_SLUGS: readonly string[] = [
|
||||
"reset-password",
|
||||
"manage",
|
||||
"api",
|
||||
"ws",
|
||||
"assets",
|
||||
"emojibase",
|
||||
];
|
||||
|
||||
/**
|
||||
* A well-formed category slug: lowercase alphanumerics,
|
||||
* hyphen-separated, no leading, trailing or double hyphen.
|
||||
* Matches a well-formed category slug, made of lowercase letters and digits
|
||||
* joined by single hyphens.
|
||||
*/
|
||||
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Checks whether `slug` is well-formed (see {@link CATEGORY_SLUG_RE}). */
|
||||
/** Checks whether `slug` matches {@link CATEGORY_SLUG_RE}. */
|
||||
export function isValidSlugFormat(slug: string): boolean {
|
||||
return CATEGORY_SLUG_RE.test(slug);
|
||||
}
|
||||
@@ -39,8 +45,3 @@ export function isValidSlugFormat(slug: string): boolean {
|
||||
export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.includes(slug);
|
||||
}
|
||||
|
||||
/** Checks whether `slug` is well-formed and not reserved. */
|
||||
export function isSlugAvailable(slug: string): boolean {
|
||||
return isValidSlugFormat(slug) && !isReservedSlug(slug);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Recognises links to this site's own pages, which previews resolve from the
|
||||
* database instead of fetching.
|
||||
* Recognises links to this site's own pages. The self provider previews them
|
||||
* from the database, and remote/fetch.ts accepts them as dump links even on a
|
||||
* private host such as localhost.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Serving files from disk with ETags and byte ranges, streamed a chunk at a
|
||||
* time.
|
||||
* Sends files from disk with ETags and byte ranges, streamed a chunk at a
|
||||
* time. The files and thumbnails routes call it, and lib/upload.ts for the
|
||||
* other uploads.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -67,21 +68,24 @@ function matchesEtag(header: string | null, etag: string): boolean {
|
||||
return header.split(",").some((tag) => strip(tag) === strip(etag));
|
||||
}
|
||||
|
||||
/** How {@link serveFile} answers. */
|
||||
/**
|
||||
* Passed to {@link serveFile} by its caller, which knows the file's type and
|
||||
* how long it may be cached.
|
||||
*/
|
||||
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). */
|
||||
/**
|
||||
* Answers a single `Range: bytes=…` with a 206. The files route sets it, so
|
||||
* that audio and video can seek.
|
||||
*/
|
||||
ranges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a file from disk with an ETag (a 304 when the client already has it)
|
||||
* and, when `ranges` is set, single byte ranges. The body is streamed.
|
||||
*
|
||||
* Returns false, having sent nothing, when the file doesn't exist.
|
||||
* Sends a file from disk with an ETag, answering 304 when the client already
|
||||
* has it, and single byte ranges when `ranges` is set. It returns false, having
|
||||
* sent nothing, when the file doesn't exist, and leaves the 404 to the caller.
|
||||
*/
|
||||
export async function serveFile(
|
||||
ctx: Context,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Generates the full favicon set and web manifest from a single emoji.
|
||||
* Renders the site's favicons and web manifest from its emoji. The `icons`
|
||||
* task (`api/scripts/generate-icons.ts`) runs it before the server starts, so
|
||||
* the emoji can change without a rebuild.
|
||||
*
|
||||
* 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.
|
||||
* The glyph comes from Twemoji as plain vector paths, so rendering it needs no
|
||||
* emoji font, which the Alpine runtime image lacks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,9 +17,10 @@ const APPLE_SIZE = 180;
|
||||
const ICO_SIZES = [16, 32, 48];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the name Twemoji files `emoji` under: its codepoints in hex, joined
|
||||
* with "-", without variation selectors (FE0F) unless the emoji is a ZWJ
|
||||
* sequence. `lib/index-html.ts` also uses it as the icon URLs' cache-busting
|
||||
* version.
|
||||
*/
|
||||
export function emojiToCodepoint(emoji: string): string {
|
||||
const cleaned = emoji.includes("") ? emoji : emoji.replace(/️/g, "");
|
||||
@@ -47,7 +46,7 @@ function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
/** Packs PNG payloads into a (PNG-compressed) .ico container. */
|
||||
/** Packs PNG images into an .ico file, which may hold PNGs as they are. */
|
||||
function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
|
||||
const HEADER = 6;
|
||||
const ENTRY = 16;
|
||||
@@ -99,24 +98,23 @@ function buildManifest(siteName: string, bgColor: string): string {
|
||||
return JSON.stringify(manifest, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/** What {@link generateSiteIcons} renders, and where. */
|
||||
/**
|
||||
* Passed to {@link generateSiteIcons} by `api/scripts/generate-icons.ts`, from
|
||||
* the site's config.
|
||||
*/
|
||||
export interface GenerateSiteIconsOptions {
|
||||
/** Emoji to render the icons from. */
|
||||
emoji: string;
|
||||
/** Directory to write the assets into (created if missing). */
|
||||
/** Created if missing. */
|
||||
outDir: string;
|
||||
/** Site name written into the manifest. */
|
||||
siteName: string;
|
||||
/**
|
||||
* Background color for the Apple touch icon and manifest (iOS ignores
|
||||
* transparency).
|
||||
*/
|
||||
/** Fills the Apple touch icon, and the manifest's theme and background. */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16–512),
|
||||
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`.
|
||||
* Writes `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16 to 512),
|
||||
* `apple-touch-icon.png` and `manifest.webmanifest` into `opts.outDir`, the
|
||||
* directory the site's static files are served from.
|
||||
*
|
||||
* @throws {Error} When the emoji's glyph can't be fetched.
|
||||
*/
|
||||
@@ -137,7 +135,7 @@ export async function generateSiteIcons(
|
||||
for (const size of PNG_SIZES) {
|
||||
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
|
||||
}
|
||||
// Apple touch icon gets a solid background — iOS doesn't honor transparency.
|
||||
// iOS draws a transparent touch icon on black.
|
||||
await Deno.writeFile(
|
||||
out("apple-touch-icon.png"),
|
||||
renderPng(svg, APPLE_SIZE, bgColor),
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
/**
|
||||
* URL slugs for records that have a title.
|
||||
* Builds the URL slugs of dumps and playlists from their titles. The dump and
|
||||
* playlist services store the slug when the record is created or retitled.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A UUID, which a route parameter may carry instead of a slug. */
|
||||
/**
|
||||
* Matches a UUID. A dump or playlist route takes either an id or a slug, and
|
||||
* the services test the parameter with this to know which column to look up.
|
||||
*/
|
||||
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.
|
||||
* Turns `title` into lowercase ASCII words joined by dashes, at most 60
|
||||
* characters long. Accents are dropped, and a title with no ASCII letter or
|
||||
* digit gives `untitled`.
|
||||
*/
|
||||
export function slugify(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
|
||||
.replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash
|
||||
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes
|
||||
// NFD splits an accented letter into the letter and a combining mark.
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.substring(0, 60);
|
||||
return slug || "untitled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the slug of a record: its title's slug followed by the start of its
|
||||
* `id`, so it stays unique by construction.
|
||||
* Returns a record's slug, its title's {@link slugify} followed by the first 8
|
||||
* characters of `id`. The suffix keeps two records with the same title apart.
|
||||
*/
|
||||
export function makeSlug(title: string, id: string): string {
|
||||
return `${slugify(title)}-${id.substring(0, 8)}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Serving the built frontend: static files, and `index.html` with its
|
||||
* placeholders filled for every client-side route.
|
||||
* Serves the built frontend, as its static files and, for every client-side
|
||||
* route, `index.html` with its placeholders filled. main.ts installs it after
|
||||
* the routers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,38 +9,39 @@
|
||||
import { Context, Next, send } from "@oak/oak";
|
||||
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,
|
||||
) {
|
||||
const html = await loadIndexHtml(root);
|
||||
context.response.type = "text/html";
|
||||
// Always revalidated: it names the hashed chunks of the current build.
|
||||
// It names the hashed chunks of the current build, so it is always
|
||||
// revalidated.
|
||||
context.response.headers.set("Cache-Control", "no-cache");
|
||||
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).
|
||||
* Prefixes the build output whose URL changes whenever its content does, which
|
||||
* are 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the middleware that serves a static file from the first of
|
||||
* `staticPaths` that has it, and `index.html` of the first for any other path.
|
||||
* Hashed build output is cached for a year, and a missing chunk is a 404.
|
||||
* main.ts passes dist/ then public/.
|
||||
*/
|
||||
export function routeStaticFilesFrom(
|
||||
staticPaths: string[],
|
||||
): (context: Context<Record<string, object>>, next: Next) => Promise<void> {
|
||||
return async (context: Context<Record<string, object>>, next: Next) => {
|
||||
// The last middleware: it always answers, so it never calls `next`.
|
||||
return async (context: Context<Record<string, object>>, _next: Next) => {
|
||||
const pathname = context.request.url.pathname;
|
||||
|
||||
// Serve index.html with runtime placeholder replacement
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
await serveIndexHtml(context, staticPaths[0]);
|
||||
return;
|
||||
@@ -53,7 +55,8 @@ export function routeStaticFilesFrom(
|
||||
const compress = context.request.headers.has("accept-encoding");
|
||||
for (const path of staticPaths) {
|
||||
try {
|
||||
// send() picks a prebuilt .br or .gz sibling when the browser accepts it.
|
||||
// send() picks a prebuilt .br or .gz sibling when the browser accepts
|
||||
// it.
|
||||
await send(context, pathname, {
|
||||
root: path,
|
||||
brotli: compress,
|
||||
@@ -74,8 +77,7 @@ export function routeStaticFilesFrom(
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html so client-side routes work on direct navigation
|
||||
// Any other path is a client-side route, opened directly or reloaded.
|
||||
await serveIndexHtml(context, staticPaths[0]);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
/**
|
||||
* Checking uploaded images and serving uploaded files back.
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import { serveFile } from "./serve-file.ts";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
ATTACHMENTS_DIR,
|
||||
AVATARS_DIR,
|
||||
DUMPS_DIR,
|
||||
MAX_IMAGE_SIZE_BYTES,
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
|
||||
export {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
ATTACHMENTS_DIR,
|
||||
AVATARS_DIR,
|
||||
DUMPS_DIR,
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
UPLOADS_DIR,
|
||||
};
|
||||
import { dirname } from "node:path";
|
||||
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* bytes, or null for anything else. These four are the only image types
|
||||
* accepted anywhere. The type the browser declared is never
|
||||
* trusted, since the file is later served with the type found here.
|
||||
*/
|
||||
export function detectImageMime(data: Uint8Array): string | null {
|
||||
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
||||
@@ -52,7 +38,8 @@ export function detectImageMime(data: Uint8Array): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an uploaded image's size and content, and returns its MIME type.
|
||||
* Checks an uploaded image's size and content, and returns its MIME type,
|
||||
* which the route stores to serve the image with.
|
||||
*
|
||||
* @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
|
||||
* isn't a JPEG, PNG, GIF or WebP image.
|
||||
@@ -60,15 +47,13 @@ export function detectImageMime(data: Uint8Array): string | null {
|
||||
export function validateImageUpload(data: Uint8Array): string {
|
||||
if (data.length > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 5 MB)",
|
||||
`File too large (max ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)} MB)`,
|
||||
);
|
||||
}
|
||||
const mime = detectImageMime(data);
|
||||
if (!mime) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
||||
);
|
||||
@@ -77,9 +62,10 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds
|
||||
* (privately when `isPrivate`). Answers 404 and resolves false when the file
|
||||
* is missing.
|
||||
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
|
||||
* and only by the browser when `isPrivate`. It answers 404 and resolves false
|
||||
* when the file is missing. The caller checks first that the viewer may see
|
||||
* it.
|
||||
*/
|
||||
export async function serveUploadedFile(
|
||||
ctx: Context,
|
||||
@@ -89,6 +75,8 @@ export async function serveUploadedFile(
|
||||
isPrivate = false,
|
||||
): Promise<boolean> {
|
||||
ctx.response.headers.set("Content-Disposition", "inline");
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes, and a frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
@@ -98,3 +86,24 @@ export async function serveUploadedFile(
|
||||
if (!served) ctx.response.status = 404;
|
||||
return served;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `data` to `filePath`, replacing whatever is there only once the whole
|
||||
* file is written, so a failed write leaves the old file whole. The upload
|
||||
* routes call it before recording the new file, so that clients told of it
|
||||
* never fetch it early.
|
||||
*/
|
||||
export async function replaceUploadedFile(
|
||||
filePath: string,
|
||||
data: Uint8Array,
|
||||
): Promise<void> {
|
||||
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
||||
await Deno.mkdir(dirname(filePath), { recursive: true });
|
||||
try {
|
||||
await Deno.writeFile(tmpPath, data);
|
||||
await Deno.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
await Deno.remove(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
18
api/main.ts
18
api/main.ts
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Builds the Oak application and, when run directly, listens on
|
||||
* {@link LISTEN_HOST}:{@link PORT}. A request goes through the error
|
||||
* middleware, the OG tags and CORS, then the routers, and last the built
|
||||
* frontend, which answers every path no router did.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -33,16 +34,19 @@ import { errorMiddleware } from "./middleware/error.ts";
|
||||
import { ogMiddleware } from "./middleware/og.ts";
|
||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||
|
||||
/** The application, exported so the tests can drive it without listening. */
|
||||
/**
|
||||
* The whole server, exported so the tests' helpers can send it requests
|
||||
* without listening.
|
||||
*/
|
||||
const app: Application = new Application();
|
||||
|
||||
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
||||
|
||||
app.use(errorMiddleware);
|
||||
app.use(ogMiddleware);
|
||||
// Only invoke oakCors when the origin is present and allowed.
|
||||
// The library calls next() without await when origin is falsy, which
|
||||
// corrupts the response in Oak's async middleware chain.
|
||||
// oakCors calls next() without awaiting it when there is no origin, which
|
||||
// corrupts the response in Oak's async chain. So it only sees requests from an
|
||||
// allowed origin, and the others get no CORS headers.
|
||||
app.use(async (ctx, next) => {
|
||||
const origin = ctx.request.headers.get("origin");
|
||||
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Middleware gating routes on authentication, the admin role, or a
|
||||
* permission.
|
||||
* Gates routes on a session token, and on a permission after it. The routers
|
||||
* put these middleware before a handler; a route open to anonymous callers
|
||||
* calls `parseOptionalAuth` (`lib/auth.ts`) instead.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,22 +13,22 @@ import {
|
||||
type State,
|
||||
} from "@oak/oak";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import type { Permission } from "../../shared/permissions.ts";
|
||||
|
||||
/** The request state once {@link authMiddleware} has run. */
|
||||
/**
|
||||
* Set on `ctx.state` by {@link authMiddleware} for the handlers after it.
|
||||
* `user` carries the caller's live role, not the one in their token.
|
||||
*/
|
||||
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 {@link authMiddleware}). Generic over the route path so
|
||||
* `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
* The context type of a handler behind {@link authMiddleware}. Pass the route
|
||||
* path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed.
|
||||
*/
|
||||
export type AuthContext<
|
||||
R extends string = string,
|
||||
@@ -35,8 +36,8 @@ export type AuthContext<
|
||||
> = RouterContext<R, P, AuthState>;
|
||||
|
||||
/**
|
||||
* Requires a valid session token in the `Authorization` header and puts its
|
||||
* caller in `ctx.state.user`.
|
||||
* Requires a valid session token in the `Authorization` header, and puts its
|
||||
* payload in `ctx.state.user` for the rest of the route.
|
||||
*
|
||||
* @throws {APIException} 401 when the token is missing or invalid.
|
||||
*/
|
||||
@@ -47,20 +48,14 @@ export async function authMiddleware<R extends string>(
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Missing or invalid token",
|
||||
);
|
||||
throw new APIException(401, "Missing or invalid token");
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
// Also checks the user still exists and hasn't changed their password since
|
||||
// the token was issued, and takes the live role from the DB.
|
||||
const payload = await authenticate(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
||||
throw new APIException(401, "Invalid token");
|
||||
}
|
||||
|
||||
ctx.state.user = payload;
|
||||
@@ -69,30 +64,11 @@ export async function authMiddleware<R extends string>(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Promise<void> {
|
||||
if (ctx.state.user.role !== "admin") {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Admin access required",
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns a middleware that requires the caller to hold `permission`. It must
|
||||
* run after {@link authMiddleware}, as in
|
||||
* `router.patch(path, authMiddleware, requirePermission("user:manage"), …)`.
|
||||
* The returned middleware throws an {@link APIException} (403) when the caller
|
||||
* lacks the permission.
|
||||
*/
|
||||
export function requirePermission(
|
||||
permission: Permission,
|
||||
@@ -102,11 +78,7 @@ export function requirePermission(
|
||||
next: Next,
|
||||
): Promise<void> => {
|
||||
if (!ctx.state.user || !can(ctx.state.user, permission)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Insufficient permissions",
|
||||
);
|
||||
throw new APIException(403, "Insufficient permissions");
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Turns errors thrown while handling a request into `APIFailure` responses.
|
||||
* Turns the errors a request's handling throws into `APIFailure` responses.
|
||||
* main.ts installs it first, so it catches what every later middleware and
|
||||
* route throws.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,8 +12,9 @@ import { APIErrorCode, APIFailure } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
|
||||
/**
|
||||
* Answers an {@link APIException} with its status and code, and anything else
|
||||
* with a 500 `SERVER_ERROR`, logging both.
|
||||
* Answers an {@link APIException} with its status, code and message. Anything
|
||||
* else is logged with its stack and answered with a 500 `SERVER_ERROR` whose
|
||||
* message gives nothing away.
|
||||
*/
|
||||
export async function errorMiddleware(
|
||||
ctx: Context,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Answers a link to a dump, a profile or a public playlist with `index.html`
|
||||
* carrying that page's Open Graph and Twitter card tags, for the link previews
|
||||
* of chat apps and social sites, which don't run the app. main.ts installs it
|
||||
* before the routers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +11,7 @@ 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 { OG_SITE_NAME } from "../config.ts";
|
||||
import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
|
||||
import { loadIndexHtml } from "../lib/index-html.ts";
|
||||
|
||||
interface OGMeta {
|
||||
@@ -18,8 +19,8 @@ interface OGMeta {
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
/**
|
||||
* True only for real artwork — a square site icon must not claim a wide
|
||||
* card.
|
||||
* Set for a dump's artwork only. It asks for the large card, which would
|
||||
* stretch a square icon or avatar.
|
||||
*/
|
||||
imageIsWide?: boolean;
|
||||
url: string;
|
||||
@@ -39,7 +40,7 @@ function buildTags(meta: OGMeta): string {
|
||||
: "summary";
|
||||
const tags = [
|
||||
`<title>${escapeAttr(meta.title)}</title>`,
|
||||
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
||||
`<meta property="og:site_name" content="${escapeAttr(OG_SITE_NAME)}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:url" content="${escapeAttr(meta.url)}" />`,
|
||||
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
|
||||
@@ -68,8 +69,8 @@ function inject(html: string, meta: OGMeta): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filled `index.html` of the build, or of the working directory
|
||||
* when there is no build; null when there is neither.
|
||||
* Returns the filled `index.html` of dist/, or else the source one, which is
|
||||
* all there is in development. Returns null when neither can be read.
|
||||
*/
|
||||
async function findIndexHtml(): Promise<string | null> {
|
||||
for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) {
|
||||
@@ -83,13 +84,16 @@ async function findIndexHtml(): Promise<string | null> {
|
||||
}
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
/** A profile, with an optional `/~/<tab>` suffix so its tabs keep OG meta. */
|
||||
/** Matches a profile, and its tabs under `/~/<tab>`. */
|
||||
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* Serves `index.html` with OG tags for a dump, user or public playlist page
|
||||
* that exists, and passes every other request on.
|
||||
* Serves `index.html` with the tags of the dump, user or public playlist a
|
||||
* path names, and passes the request on when it names none that exists or
|
||||
* the visitor may see. The page's URL is on `FRONTEND_URL` and the images' on
|
||||
* `PUBLIC_URL`, not on the request's origin, which a reverse proxy may have
|
||||
* rewritten to an internal host or plain http.
|
||||
*/
|
||||
export async function ogMiddleware(
|
||||
ctx: Context,
|
||||
@@ -101,8 +105,8 @@ export async function ogMiddleware(
|
||||
return await next();
|
||||
}
|
||||
|
||||
const origin = ctx.request.url.origin;
|
||||
const pageUrl = `${origin}${pathname}`;
|
||||
const origin = PUBLIC_URL;
|
||||
const pageUrl = `${FRONTEND_URL}${pathname}`;
|
||||
|
||||
let meta: OGMeta | null = null;
|
||||
|
||||
@@ -119,9 +123,8 @@ export async function ogMiddleware(
|
||||
} else if (dump.richContent?.thumbnailUrl) {
|
||||
imageUrl = dump.richContent.thumbnailUrl;
|
||||
}
|
||||
// No artwork: fall back to our own site icon rather than the target's
|
||||
// favicon, which is typically too small to survive a social card (and may
|
||||
// be hotlink-protected).
|
||||
// Without artwork, the site's icon: the linked site's favicon is too
|
||||
// small for a card, and may refuse hotlinking.
|
||||
meta = {
|
||||
title: dump.title,
|
||||
description: dump.comment,
|
||||
@@ -129,7 +132,7 @@ export async function ogMiddleware(
|
||||
imageIsWide: !!imageUrl,
|
||||
url: pageUrl,
|
||||
};
|
||||
} catch { /* not found or private — serve default */ }
|
||||
} catch { /* Unknown, or private. */ }
|
||||
} else if (userMatch) {
|
||||
try {
|
||||
const user = getUserByUsername(userMatch[1]);
|
||||
@@ -142,10 +145,11 @@ export async function ogMiddleware(
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
url: pageUrl,
|
||||
};
|
||||
} catch { /* not found */ }
|
||||
} catch { /* Unknown. */ }
|
||||
} else if (playlistMatch) {
|
||||
try {
|
||||
const playlist = getPlaylistById(playlistMatch[1]);
|
||||
// getPlaylistById doesn't check visibility, unlike getDump.
|
||||
if (playlist.isPublic) {
|
||||
const imageUrl = playlist.imageMime
|
||||
? `${origin}/api/playlists/${playlist.id}/image`
|
||||
@@ -157,7 +161,7 @@ export async function ogMiddleware(
|
||||
url: pageUrl,
|
||||
};
|
||||
}
|
||||
} catch { /* not found or private */ }
|
||||
} catch { /* Unknown. */ }
|
||||
}
|
||||
|
||||
if (!meta) return await next();
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
/**
|
||||
* The payloads of the tokens the server signs, and their guards.
|
||||
* Types the payloads of session and password reset tokens, and guards them.
|
||||
* `lib/jwt.ts` signs the tokens and runs the guards when it verifies one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isRole, type Role } from "../../shared/user.ts";
|
||||
|
||||
/** What a session token carries. */
|
||||
/**
|
||||
* Signed into a session token by `createJWT`, and returned by `authenticate`
|
||||
* to the auth middleware as `ctx.state.user`. By then `role` is the user's live
|
||||
* role, while `username` is still the one at issue time.
|
||||
*/
|
||||
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. */
|
||||
/** Seconds since the epoch. */
|
||||
exp: number;
|
||||
/** When the token was issued, in seconds since the epoch. */
|
||||
/** Seconds since the epoch, compared with `users.tokens_valid_after`. */
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped
|
||||
* token's payload.
|
||||
* Checks whether `obj` is an {@link AuthPayload}. A media or reset token's
|
||||
* payload, which has a `purpose`, never is.
|
||||
*/
|
||||
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
// Purpose-scoped tokens (password reset, media) are never session tokens.
|
||||
// A media or reset token must not pass for a session token.
|
||||
!("purpose" in obj) &&
|
||||
"userId" in obj && typeof obj.userId === "string" &&
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
@@ -37,17 +37,21 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
"exp" in obj && typeof obj.exp === "number";
|
||||
}
|
||||
|
||||
/** What a password reset token carries. */
|
||||
/**
|
||||
* Signed into a reset link's token by `createPasswordResetToken`, and returned
|
||||
* by `verifyPasswordResetToken` to `resetPassword`.
|
||||
*/
|
||||
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. */
|
||||
/** Seconds since the epoch. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetPayload}. */
|
||||
/**
|
||||
* Checks whether `obj` is a {@link PasswordResetPayload}. It doesn't check
|
||||
* `exp`, which `jwtVerify` has already enforced.
|
||||
*/
|
||||
export function isPasswordResetPayload(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetPayload {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `categories` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Category}.
|
||||
* Types the rows the category service reads from `categories`, and turns them
|
||||
* into the {@link Category} the category routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,25 +8,19 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
|
||||
/** A row of the `categories` table. */
|
||||
/** Returned by the category service's `SELECT`s on `categories`. */
|
||||
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. */
|
||||
/** Sorts the categories, lowest first. */
|
||||
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}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link CategoryRow}. */
|
||||
export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -38,7 +32,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}. */
|
||||
/** Converts `row` to the {@link Category} the category routes send. */
|
||||
export function categoryRowToApi(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `chat_messages` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link ChatMessage}.
|
||||
* Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends.
|
||||
* Only `services/chat-service.ts` uses it, on the rows of its one query.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,44 +9,30 @@ 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.
|
||||
* Returned by chat-service's query, which joins each message to its author and
|
||||
* to the message it replies to. `reply_to_id` is kept when the message it
|
||||
* points to is deleted, while `reply_to_author` and `reply_to_body` then read
|
||||
* null.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */
|
||||
reply_to_body: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link ChatMessageRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row read by chat-service, is a
|
||||
* {@link ChatMessageRow}.
|
||||
*/
|
||||
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -67,7 +53,10 @@ 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}. */
|
||||
/**
|
||||
* Converts a row to the {@link ChatMessage} that chat-service returns, turning
|
||||
* its timestamps into Dates and its nulls into absent fields.
|
||||
*/
|
||||
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `comments` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Comment}.
|
||||
* Types the rows the comment service reads from `comments`, and turns them into
|
||||
* the {@link Comment} the comment routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,38 +8,31 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
|
||||
/** A row of the `comments` table, joined with its author. */
|
||||
/**
|
||||
* Returned by the comment service's `SELECT`s on `comments`, joined with
|
||||
* `users` for the `author_` columns.
|
||||
*/
|
||||
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. */
|
||||
/** It is null on a top-level comment. */
|
||||
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.
|
||||
* 1 once deleted. The body is emptied but the row stays, so its replies keep
|
||||
* their place in the thread.
|
||||
*/
|
||||
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}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link CommentRow}. */
|
||||
export function isCommentRow(
|
||||
obj: unknown,
|
||||
): obj is CommentRow {
|
||||
@@ -61,7 +54,10 @@ export function isCommentRow(
|
||||
obj.author_avatar_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Comment}. */
|
||||
/**
|
||||
* Converts `row` to the {@link Comment} the comment routes send and the
|
||||
* WebSocket broadcasts.
|
||||
*/
|
||||
export function commentRowToApi(row: CommentRow): Comment {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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}.
|
||||
* Maps the `dumps` table to the API's {@link Dump}. Every service that reads
|
||||
* dumps selects them with the column lists here, checks each row with
|
||||
* {@link isDumpRow} and converts it with {@link dumpRowToApi}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,67 +9,65 @@
|
||||
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. */
|
||||
/**
|
||||
* Read from the `dumps` table with {@link DUMP_SELECT_COLUMNS}, or written back
|
||||
* from a {@link Dump} by {@link dumpApiToRow}. It lacks `url_canonical`, which
|
||||
* only the duplicate lookup reads.
|
||||
*/
|
||||
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. */
|
||||
/** The link as the poster submitted it. */
|
||||
url: string | null;
|
||||
/** The link's preview, as {@link RichContent} JSON. */
|
||||
/** {@link RichContent} as 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). */
|
||||
/** Computed by a subquery, which counts the comments that aren't deleted. */
|
||||
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;
|
||||
/** Any other column the query selects. */
|
||||
/** Lets a query select more columns, such as a join's. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists the stored columns of {@link DumpRow}. A column added to the table must
|
||||
* be added here, to {@link DumpRow} and to {@link isDumpRow}.
|
||||
*/
|
||||
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";
|
||||
// The same columns, for a query that aliases `dumps` as `d`.
|
||||
const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`)
|
||||
.join(", ");
|
||||
|
||||
/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */
|
||||
/**
|
||||
* Selects a whole {@link DumpRow}, the stored columns and the computed
|
||||
* `comment_count`.
|
||||
*/
|
||||
export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
||||
|
||||
/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */
|
||||
/**
|
||||
* Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to
|
||||
* join it with other tables.
|
||||
*/
|
||||
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}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
|
||||
* each column's type, not that `kind` is `url` or `file`.
|
||||
*/
|
||||
export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -99,7 +98,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
obj.custom_thumbnail_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Dump}. */
|
||||
/**
|
||||
* Converts a row to the API's {@link Dump}, parsing its preview's JSON. It
|
||||
* leaves `categoryIds` out: the services add them with `attachCategoryIds`.
|
||||
*/
|
||||
export function dumpRowToApi(row: DumpRow): Dump {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -124,7 +126,10 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link Dump} back to a row. */
|
||||
/**
|
||||
* Converts a {@link Dump} back to a row, for a service that writes an edited
|
||||
* dump's columns.
|
||||
*/
|
||||
export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
return {
|
||||
id: dump.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The `follows` table: its row type and its guard.
|
||||
* Types and guards the rows of `follows`, which `follow-service.ts` reads to
|
||||
* answer what a user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,25 +8,22 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* A row of the `follows` table: one user following either another user or a
|
||||
* playlist.
|
||||
* Read by `getFollowStatus`. Exactly one of `followed_user_id` and
|
||||
* `followed_playlist_id` is set, as the table's CHECK constraint requires.
|
||||
*/
|
||||
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}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `getFollowStatus` selected, is a
|
||||
* {@link FollowRow}.
|
||||
*/
|
||||
export function isFollowRow(
|
||||
obj: unknown,
|
||||
): obj is FollowRow {
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
/**
|
||||
* The `invites` table: its row type and its guard.
|
||||
* Types and guards the rows of `invites`, which `invite-service.ts` reads to
|
||||
* check an invite before sign-up.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `invites` table. */
|
||||
/**
|
||||
* Stored by `createInvite`, and read back by `validateInvite` when the invite
|
||||
* link is opened or used to sign up.
|
||||
*/
|
||||
export interface InviteRow {
|
||||
/** The invite's token, which registering redeems. */
|
||||
/** The random token the invite link carries, which is the primary key. */
|
||||
token: string;
|
||||
/** The id of the user who made the invite. */
|
||||
/** Becomes the new user's `invited_by`. */
|
||||
inviter_id: string;
|
||||
/** When the invite was redeemed; null while unused. */
|
||||
/** Set when an account is created with the invite. */
|
||||
used_at: string | null;
|
||||
/** When the invite was made. */
|
||||
/** Starts the 7-day window in which the invite works. */
|
||||
created_at: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link InviteRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `validateInvite` selected, is an
|
||||
* {@link InviteRow}.
|
||||
*/
|
||||
export function isInviteRow(
|
||||
obj: unknown,
|
||||
): obj is InviteRow {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `notifications` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link Notification}.
|
||||
* Types and guards the rows of `notifications`, and converts them to the
|
||||
* {@link Notification} that `GET /api/notifications` answers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,30 +12,33 @@ import type {
|
||||
NotificationType,
|
||||
} from "../../shared/notification.ts";
|
||||
|
||||
/** A row of the `notifications` table. */
|
||||
/**
|
||||
* Stored by the triggers of `notification-service.ts`, and read back by
|
||||
* `getNotificationsForUser`.
|
||||
*/
|
||||
export interface NotificationRow {
|
||||
/** The notification's id. */
|
||||
id: string;
|
||||
/** The id of the user notified. */
|
||||
user_id: string;
|
||||
/** The notification's {@link NotificationType}. */
|
||||
/** A {@link NotificationType}, not checked by the guard. */
|
||||
type: string;
|
||||
/** The notification's {@link NotificationData}, as JSON. */
|
||||
/** The {@link NotificationData} as JSON, not checked by the guard. */
|
||||
data: string;
|
||||
/** 1 once the user has read it. */
|
||||
/** 0 or 1. */
|
||||
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.
|
||||
* Names the event that caused the notification. A unique index keeps a
|
||||
* second row with the same `(user_id, source_key)` out; null allows
|
||||
* duplicates.
|
||||
*/
|
||||
source_key: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link NotificationRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `getNotificationsForUser` selected, is a
|
||||
* {@link NotificationRow}.
|
||||
*/
|
||||
export function isNotificationRow(
|
||||
obj: unknown,
|
||||
): obj is NotificationRow {
|
||||
@@ -50,7 +53,11 @@ export function isNotificationRow(
|
||||
(typeof obj.source_key === "string" || obj.source_key === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Notification}. */
|
||||
/**
|
||||
* Converts a row to the {@link Notification} the client gets.
|
||||
*
|
||||
* @throws {SyntaxError} When `data` isn't valid JSON.
|
||||
*/
|
||||
export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
/**
|
||||
* The `password_reset_tokens` table: its row type and its guard.
|
||||
* Types and guards the rows of `password_reset_tokens`, which
|
||||
* `password-reset-service.ts` reads to make each reset link work once.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `password_reset_tokens` table. */
|
||||
/**
|
||||
* Stored by `requestPasswordReset` when it emails a reset link, and read back
|
||||
* by `resetPassword` when the link is used.
|
||||
*/
|
||||
export interface PasswordResetTokenRow {
|
||||
/** The reset token. */
|
||||
/** The signed token itself, which the reset link carries. */
|
||||
token: string;
|
||||
/** The id of the user whose password it resets. */
|
||||
user_id: string;
|
||||
/** When the token stops working. */
|
||||
/**
|
||||
* Lets `db/startup.ts` purge old rows. The token's own `exp` is what ends
|
||||
* the link.
|
||||
*/
|
||||
expires_at: string;
|
||||
/** When the token was used; null while unused. */
|
||||
/** Set when the link is used, and reset to null if the reset then fails. */
|
||||
used_at: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `resetPassword` selected, is a
|
||||
* {@link PasswordResetTokenRow}.
|
||||
*/
|
||||
export function isPasswordResetTokenRow(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetTokenRow {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `playlists` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Playlist}.
|
||||
* Types the rows the playlist and follow services read from `playlists`, and
|
||||
* turns them into the {@link Playlist} the API sends.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,33 +9,24 @@ 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.
|
||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`. A
|
||||
* query may add `dump_count` and `owner_username` columns, which
|
||||
* {@link playlistRowToApi} carries over when present.
|
||||
*/
|
||||
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}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link PlaylistRow}. */
|
||||
export function isPlaylistRow(
|
||||
obj: unknown,
|
||||
): obj is PlaylistRow {
|
||||
@@ -54,7 +45,7 @@ export function isPlaylistRow(
|
||||
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Playlist}. */
|
||||
/** Converts `row` to the {@link Playlist} the playlist routes send. */
|
||||
export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
25
api/model/rows.ts
Normal file
25
api/model/rows.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Checks the rows of a list query, for the services. Every service answers a
|
||||
* row that doesn't have its table's shape with a 500 rather than leaving it
|
||||
* out, so that damage shows instead of making items silently vanish.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
|
||||
/**
|
||||
* Returns `rows` turned by `toApi`, once `isRow` has accepted each.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't pass `isRow`.
|
||||
*/
|
||||
export function parseRows<R, T>(
|
||||
rows: unknown[],
|
||||
isRow: (row: unknown) => row is R,
|
||||
toApi: (row: R) => T,
|
||||
): T[] {
|
||||
if (!rows.every(isRow)) {
|
||||
throw new APIException(500, "Malformed data in the database");
|
||||
}
|
||||
return rows.map(toApi);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Types and guards the rows of `users`, and converts them to the {@link User}
|
||||
* that `user-service.ts` returns. A route turns a `User` into what it may send
|
||||
* with {@link toPublicUser} or {@link toSelfUser}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,56 +14,65 @@ import {
|
||||
type SelfUser,
|
||||
} from "../../shared/user.ts";
|
||||
|
||||
/** A user as stored: what they see of themselves, plus the password hash. */
|
||||
/**
|
||||
* Returned by the lookups of `user-service.ts`. It must never be sent as is:
|
||||
* it carries the password hash.
|
||||
*/
|
||||
export interface User extends SelfUser {
|
||||
/** The scrypt hash of the password, as `hash.salt`. */
|
||||
/** From `hashPassword`, as `hash.salt`. */
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
/** Returns what anyone may see of `user`: no hash, no email. */
|
||||
/**
|
||||
* Returns what anyone may see of `user`, without the hash and the email. Every
|
||||
* route that sends another user's profile goes through it.
|
||||
*/
|
||||
export function toPublicUser(
|
||||
{ passwordHash: _, email: _email, ...user }: User,
|
||||
): PublicUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Returns what `user` sees of themselves: everything but the hash. */
|
||||
/**
|
||||
* Returns what `user` sees of themselves, without the hash, for the routes
|
||||
* that answer the caller's own account.
|
||||
*/
|
||||
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** A row of the `users` table. */
|
||||
/**
|
||||
* Selected by `user-service.ts` and `follow-service.ts`, and converted by
|
||||
* {@link userRowToApi}. It leaves out `chat_read_at` and `tokens_valid_after`,
|
||||
* which only the chat's read state and `getSessionState` query.
|
||||
*/
|
||||
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. */
|
||||
/** Bumped by any profile or avatar change. */
|
||||
updated_at: string | null;
|
||||
/** The avatar's type, null without one. */
|
||||
/** Null without an avatar. */
|
||||
avatar_mime: string | null;
|
||||
/** The user's profile text, in Markdown. */
|
||||
/** Markdown. */
|
||||
description: string | null;
|
||||
/** The id of the user whose invite they registered with. */
|
||||
/** Null for the seeded admin, who had no inviter. */
|
||||
invited_by: string | null;
|
||||
/**
|
||||
* The inviter's name; present only when the query joins it (`LEFT JOIN users
|
||||
* i ON i.id = u.invited_by`).
|
||||
* Joined from the inviter's row rather than stored, and undefined when the
|
||||
* query doesn't join it. The guard doesn't check it.
|
||||
*/
|
||||
invited_by_username: string | null;
|
||||
/** The user's email address. */
|
||||
email: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link UserRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row selected with the columns of {@link UserRow}, is
|
||||
* one.
|
||||
*/
|
||||
export function isUserRow(obj: unknown): obj is UserRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -82,7 +92,7 @@ export function isUserRow(obj: unknown): obj is UserRow {
|
||||
"email" in obj && typeof obj.email === "string";
|
||||
}
|
||||
|
||||
/** Converts a row to the stored {@link User}. */
|
||||
/** Converts a row to a {@link User}, turning nulls into undefined. */
|
||||
export function userRowToApi(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -100,7 +110,10 @@ export function userRowToApi(row: UserRow): User {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link User} back to a row, without the join-only columns. */
|
||||
/**
|
||||
* Converts a {@link User} back to a row, for `updateUser`. A `User` has no
|
||||
* inviter id, so `invited_by` comes out null: don't write it back.
|
||||
*/
|
||||
export function userApiToRow(user: User): UserRow {
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
/**
|
||||
* Resolves a Bandcamp page to its streamable tracks, at play time.
|
||||
*
|
||||
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can
|
||||
* 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.
|
||||
* Resolves a Bandcamp page to its streamable tracks when the native player
|
||||
* starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and
|
||||
* expire 24 hours after the page is fetched, so unlike the embed URL they
|
||||
* can't be stored in the dump's preview. The browser plays them from bcbits
|
||||
* directly.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
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.
|
||||
* Half the signatures' life, so a cached URL still has at least 12 hours left
|
||||
* when it is handed out.
|
||||
*/
|
||||
const TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** The most pages cached; the oldest write goes first. */
|
||||
/** The oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
/** Resolved pages, by {@link cacheKey}. */
|
||||
const cache = new Map<string, Tralbum>();
|
||||
|
||||
/**
|
||||
* Returns the cache key of `url`: a page is the same page regardless of query,
|
||||
* fragment or case.
|
||||
* Returns `url` without its query, fragment and trailing slashes, and with its
|
||||
* host in lowercase.
|
||||
*/
|
||||
function cacheKey(url: string): string {
|
||||
try {
|
||||
@@ -61,10 +58,10 @@ function writeCache(key: string, value: Tralbum): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the tracklist of the Bandcamp page `pageUrl`, from the cache when it
|
||||
* was resolved in the last 12 hours. `force` skips the cache: the player sets
|
||||
* it when it re-resolves a queue whose URLs expired or are about to, which a
|
||||
* cached entry could hand 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
|
||||
@@ -75,25 +72,17 @@ export async function resolveBandcamp(
|
||||
{ force = false }: { force?: boolean } = {},
|
||||
): Promise<Tralbum> {
|
||||
if (!isValidHttpUrl(pageUrl)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid URL",
|
||||
);
|
||||
throw new APIException(400, "Invalid URL");
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(pageUrl).hostname;
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL");
|
||||
throw new APIException(400, "Invalid URL");
|
||||
}
|
||||
if (!BANDCAMP_HOST_RE.test(hostname)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Not a Bandcamp URL",
|
||||
);
|
||||
throw new APIException(400, "Not a Bandcamp URL");
|
||||
}
|
||||
|
||||
const key = cacheKey(pageUrl);
|
||||
@@ -106,42 +95,24 @@ export async function resolveBandcamp(
|
||||
try {
|
||||
res = await fetchWithTimeout(pageUrl, 8000);
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Could not reach Bandcamp",
|
||||
);
|
||||
throw new APIException(502, "Could not reach Bandcamp");
|
||||
}
|
||||
// Every path out of here that doesn't read the body has to release it, or the
|
||||
// connection stays open until GC. This endpoint is unauthenticated: a caller
|
||||
// hammering random /track/<slug> URLs would otherwise leak one body a request.
|
||||
// A body left unread keeps its connection open until GC, and this endpoint
|
||||
// is public: every early return cancels it.
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
`Bandcamp returned ${res.status}`,
|
||||
);
|
||||
throw new APIException(502, `Bandcamp returned ${res.status}`);
|
||||
}
|
||||
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
||||
await res.body?.cancel();
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Bandcamp did not return a page",
|
||||
);
|
||||
throw new APIException(502, "Bandcamp did not return a page");
|
||||
}
|
||||
|
||||
const parsed = parseTralbum(await readTextCapped(res), pageUrl);
|
||||
if (!parsed) {
|
||||
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
|
||||
// with nothing streamable — all cases where the client falls back to the
|
||||
// iframe embed rather than showing an error.
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No streamable tracks on this page",
|
||||
);
|
||||
// No data, no audio or nothing streamable yet (a preorder). The client
|
||||
// plays the embed instead.
|
||||
throw new APIException(404, "No streamable tracks on this page");
|
||||
}
|
||||
|
||||
writeCache(key, parsed);
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/**
|
||||
* Parser for Bandcamp's `data-tralbum` attribute.
|
||||
*
|
||||
* Every Bandcamp track and album page carries the whole release — titles,
|
||||
* durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a
|
||||
* `data-tralbum` attribute. One fetch of an album page therefore yields every
|
||||
* track's stream URL; there is no per-track request.
|
||||
*
|
||||
* The stream URLs are signed and expire 24h after the page was fetched, which
|
||||
* is why nothing here is ever persisted to `rich_content` — see
|
||||
* `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint.
|
||||
*
|
||||
* 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.
|
||||
* Reads a release out of a Bandcamp page, for `bandcamp-streams.ts`. Every
|
||||
* track and album page carries the whole release, stream URLs included, as
|
||||
* HTML-escaped JSON in a `data-tralbum` attribute, so one fetch covers an
|
||||
* album. The format is undocumented, so every field is treated as optional.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,19 +10,16 @@
|
||||
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Matches the hostnames `bandcamp-streams.ts` accepts: bandcamp.com and its
|
||||
* subdomains, and not `bandcamp.com.example.org`. An artist on a custom domain
|
||||
* doesn't match, and the player falls back to the embed.
|
||||
*/
|
||||
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (`&quot;`, i.e. the literal text `"` inside a title) decodes twice
|
||||
* and yields a bare `"` — which lands inside the JSON string and breaks the
|
||||
* parse. Matching every entity form in one regex means each is replaced once.
|
||||
* Decodes HTML entities in a single pass. `html.ts` decodes `&` before
|
||||
* `"`, which turns a title's literal `"` (escaped as `&quot;`)
|
||||
* into a bare `"` that breaks the JSON.
|
||||
*/
|
||||
function decodeEntitiesOnce(input: string): string {
|
||||
const named: Record<string, string> = {
|
||||
@@ -53,7 +40,7 @@ function decodeEntitiesOnce(input: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the URL of the large square artwork numbered `artId`. */
|
||||
/** Returns the URL of the artwork `artId`; undefined when it isn't numeric. */
|
||||
function artworkUrl(artId: unknown): string | undefined {
|
||||
if (typeof artId !== "number" && typeof artId !== "string") return undefined;
|
||||
const id = String(artId).trim();
|
||||
@@ -63,8 +50,9 @@ function artworkUrl(artId: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the release on the Bandcamp page `html`, fetched from `sourceUrl`;
|
||||
* null when the page has no release data, no audio or no streamable track.
|
||||
* Stream URLs outside bcbits.com are dropped.
|
||||
*/
|
||||
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
|
||||
const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/);
|
||||
@@ -84,8 +72,8 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
|
||||
const t = (entry ?? {}) as Record<string, unknown>;
|
||||
const file = (t.file ?? {}) as Record<string, unknown>;
|
||||
const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null;
|
||||
// Only ever hand out bcbits URLs. Without this the endpoint would relay
|
||||
// whatever host a hostile page put in the blob straight to the browser.
|
||||
// A hostile page could put any host here, and the endpoint would hand it
|
||||
// to the browser.
|
||||
const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null;
|
||||
return {
|
||||
index,
|
||||
@@ -117,7 +105,6 @@ 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,7 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Fetches pages and images from other sites for link previews, the image proxy
|
||||
* and the Bandcamp resolver, without letting a user-supplied URL reach the
|
||||
* server's own network. Each hop must resolve to a public address, the whole
|
||||
* exchange has a deadline, and pages are read up to a cap.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,15 +21,11 @@ const FETCH_HEADERS = {
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `address` pins the connection to the IP already checked by
|
||||
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
|
||||
* back with its Location so fetchWithTimeout can check the next hop too.
|
||||
* Fetches `url` with `curl --insecure`, for a site whose TLS certificate Deno
|
||||
* rejects. Returns a Response carrying only the `Location` and `Content-Type`
|
||||
* headers, or null when curl is missing or fails. The connection is pinned to
|
||||
* `address`, the one {@link resolvePublicAddress} checked, and redirects come
|
||||
* back unfollowed so that {@link fetchWithTimeout} checks each hop.
|
||||
*/
|
||||
async function fetchViaCurl(
|
||||
url: URL,
|
||||
@@ -49,6 +46,8 @@ async function fetchViaCurl(
|
||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||
"--max-time",
|
||||
String(Math.ceil(timeoutMs / 1000)),
|
||||
// curl fails on a larger file rather than stopping at a cap, so this is
|
||||
// looser than MAX_PAGE_BYTES, where readTextCapped still cuts.
|
||||
"--max-filesize",
|
||||
String(2 * MAX_PAGE_BYTES),
|
||||
"--user-agent",
|
||||
@@ -78,20 +77,7 @@ async function fetchViaCurl(
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
|
||||
const bytes = await Deno.readFile(tmpPath);
|
||||
if (!contentType.startsWith("text/")) {
|
||||
return new Response(bytes, { status: statusCode, headers });
|
||||
}
|
||||
// Decode using the charset declared in the Content-Type header so that
|
||||
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
||||
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||
let bodyText: string;
|
||||
try {
|
||||
bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
|
||||
return new Response(bodyText, { status: statusCode, headers });
|
||||
return new Response(bytes, { status: statusCode, headers });
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
@@ -100,15 +86,15 @@ async function fetchViaCurl(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The default cap of {@link readTextCapped}. The metadata previews read is in
|
||||
* the `<head>`, and a page past this is a download.
|
||||
*/
|
||||
export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Hands `body` on, and clears the request's deadline once it has been read to
|
||||
* the end or cancelled. Until then the deadline still applies: a server that
|
||||
* answers at once and then drips its body can't hold the request open.
|
||||
* Wraps `body` so that the request's deadline `timer` is cleared only once the
|
||||
* body is read to the end or cancelled. A server that answers at once and then
|
||||
* drips its body is still cut off.
|
||||
*/
|
||||
function withDeadline(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
@@ -138,14 +124,14 @@ function withDeadline(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a response's body as text, stopping at `maxBytes` (the rest is
|
||||
* cancelled, never downloaded).
|
||||
* Reads the body of `res` up to `maxBytes`; the rest is cancelled, never
|
||||
* downloaded. The image proxy reads through it, and {@link readTextCapped}.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
export async function readBytesCapped(
|
||||
res: Response,
|
||||
maxBytes = MAX_PAGE_BYTES,
|
||||
): Promise<string> {
|
||||
if (!res.body) return "";
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array> {
|
||||
if (!res.body) return new Uint8Array();
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
@@ -167,10 +153,45 @@ export async function readTextCapped(
|
||||
offset += part.byteLength;
|
||||
if (offset >= bytes.byteLength) break;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Makes one request, following no redirects. The timeout covers the body too. */
|
||||
/**
|
||||
* Returns the charset a page declares, in its `Content-Type` header or else in
|
||||
* a `<meta>` near its top, as HTML allows; UTF-8 when it declares none.
|
||||
*/
|
||||
function declaredCharset(contentType: string, bytes: Uint8Array): string {
|
||||
const fromHeader = /charset=["']?([\w-]+)/i.exec(contentType)?.[1];
|
||||
if (fromHeader) return fromHeader;
|
||||
// Latin-1 maps every byte to a character, so the ASCII of the tag reads
|
||||
// right whatever the page's encoding.
|
||||
const head = new TextDecoder("latin1").decode(bytes.subarray(0, 1024));
|
||||
return /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1] ?? "utf-8";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the body of `res` as text, up to `maxBytes`, in the charset the page
|
||||
* declares; the rest is cancelled, never downloaded. The providers and the
|
||||
* Bandcamp and YouTube resolvers read pages through it.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
res: Response,
|
||||
maxBytes = MAX_PAGE_BYTES,
|
||||
): Promise<string> {
|
||||
const bytes = await readBytesCapped(res, maxBytes);
|
||||
const charset = declaredCharset(res.headers.get("content-type") ?? "", bytes);
|
||||
try {
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch {
|
||||
// An unknown label: TextDecoder throws rather than guess.
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one request to `url`, following no redirects, with a deadline that
|
||||
* covers the body too.
|
||||
*/
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
@@ -191,10 +212,9 @@ async function fetchOnce(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Deno/rustls rejected the server's TLS certificate (expired, self-signed,
|
||||
// or an unsupported signature algorithm) and offers no way to override
|
||||
// verification programmatically. Fall back to a `curl --insecure`
|
||||
// subprocess, which uses its own TLS stack.
|
||||
// Deno can't be told to skip certificate checks for one request, so a
|
||||
// site with an expired, self-signed or unusually signed certificate is
|
||||
// fetched with curl instead.
|
||||
const curlRes = await fetchViaCurl(url, address, timeoutMs);
|
||||
if (curlRes) return curlRes;
|
||||
|
||||
@@ -212,10 +232,10 @@ async function fetchOnce(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Fetches a user-supplied URL, following up to five redirects, each of which
|
||||
* must resolve to a public address too. `timeoutMs` bounds each hop, body
|
||||
* included. The caller must read or cancel the body, or its connection stays
|
||||
* open.
|
||||
*
|
||||
* @throws {TypeError} When a hop isn't http(s), there are too many redirects,
|
||||
* or the request fails.
|
||||
@@ -242,16 +262,17 @@ 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.
|
||||
* Checks whether `raw` is an http(s) URL on a host that isn't private, or a
|
||||
* link to one of this site's own pages. Routes and services check a URL with it
|
||||
* before storing, previewing or proxying it; it does no DNS lookup, which
|
||||
* {@link fetchWithTimeout} does.
|
||||
*/
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
// This site's own pages are fine even on a private host (e.g. localhost in
|
||||
// development): selfProvider reads them from the DB, no HTTP involved.
|
||||
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
|
||||
// This site may run on a private host (localhost in development), and the
|
||||
// self provider reads its pages from the database without fetching them.
|
||||
return !isPrivateHostname(u) || isSelfUrl(u);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* Reads a link preview's metadata out of a page's HTML with regular
|
||||
* expressions, without parsing the document. The generic provider uses most of
|
||||
* it; the Bandcamp, SoundCloud and YouTube providers read only OG tags. Each
|
||||
* function returns undefined when the page doesn't say.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Decodes the named and numeric HTML entities in `str`. */
|
||||
/**
|
||||
* Decodes the five XML entities and numeric ones in `str`. `&` goes first,
|
||||
* so `&quot;` decodes twice; `bandcamp-tralbum.ts`, which decodes JSON,
|
||||
* has its own single-pass decoder.
|
||||
*/
|
||||
function decodeHtmlEntities(str: string): string {
|
||||
return str
|
||||
.replace(/&/gi, "&")
|
||||
@@ -21,9 +27,9 @@ function decodeHtmlEntities(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the `content` of the first `<meta>` tag whose `attr` equals `value`,
|
||||
* in whichever order the attributes come. Each tag is matched on its own, so
|
||||
* the capture can't run into the next tag.
|
||||
*/
|
||||
function extractMetaContent(
|
||||
html: string,
|
||||
@@ -44,7 +50,7 @@ function extractMetaContent(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns the content of `<meta property="og:<tag>">`. */
|
||||
/** Returns the content of `<meta property="og:<tag>">`, entities decoded. */
|
||||
export function extractOgTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
@@ -53,8 +59,8 @@ export function extractOgTag(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of `<meta name="…" content="…">` (both attribute
|
||||
* orderings).
|
||||
* Returns the content of `<meta name="<name>">`, entities decoded. Twitter
|
||||
* card tags use `name`, not `property`.
|
||||
*/
|
||||
export function extractMetaName(
|
||||
html: string,
|
||||
@@ -63,20 +69,16 @@ export function extractMetaName(
|
||||
return extractMetaContent(html, "name", name);
|
||||
}
|
||||
|
||||
/** Returns the text content of the `<title>` element. */
|
||||
/** Returns the text of the `<title>` element, trimmed and entities decoded. */
|
||||
export function extractPageTitle(html: string): string | undefined {
|
||||
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
|
||||
}
|
||||
|
||||
// ── Brand color helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
|
||||
* Returns the page's `theme-color`, as written: pass it to
|
||||
* {@link normalizeCssColor}. Sites often ship one per color scheme with the
|
||||
* dark one first, so the unscoped tag wins, then the light one, then any.
|
||||
*/
|
||||
export function extractThemeColor(html: string): string | undefined {
|
||||
const tagRe = /<meta\b[^>]*>/gi;
|
||||
@@ -100,7 +102,10 @@ export function extractThemeColor(html: string): string | undefined {
|
||||
return unscoped ?? light ?? any;
|
||||
}
|
||||
|
||||
/** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
|
||||
/**
|
||||
* Returns the `color` of `<link rel="mask-icon">`, Safari's pinned-tab icon,
|
||||
* as written. The generic provider's last resort for a brand color.
|
||||
*/
|
||||
export function extractMaskIconColor(html: string): string | undefined {
|
||||
const linkRe = /<link[^>]+>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -114,8 +119,8 @@ export function extractMaskIconColor(html: string): string | 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 named colors sites actually put in `theme-color`; the rest of CSS's names
|
||||
* aren't worth carrying.
|
||||
*/
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
black: "#000000",
|
||||
@@ -145,9 +150,10 @@ function clampByte(n: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns `raw` as a lowercase `#rrggbb`, from a hex, `rgb()`, `rgba()` or
|
||||
* common named color; undefined for any other form. The generic provider
|
||||
* stores it as the preview's `accentColor`, a tint over the app's own surface,
|
||||
* so alpha is dropped.
|
||||
*/
|
||||
export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
@@ -182,17 +188,17 @@ export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── JSON-LD helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** What a page's JSON-LD says about it. */
|
||||
/**
|
||||
* Returned to the generic provider by {@link extractJsonLd}, which ranks each
|
||||
* field below the page's OG and Twitter tags.
|
||||
*/
|
||||
export interface JsonLdResult {
|
||||
/** The node's `name` or `headline`. */
|
||||
/** From `name`, or else `headline`. */
|
||||
title?: string;
|
||||
/** The node's `description`. */
|
||||
description?: string;
|
||||
/**
|
||||
* The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute
|
||||
* URL.
|
||||
* From `image`, or else `thumbnailUrl` or `thumbnail`, and only when it is
|
||||
* an absolute URL.
|
||||
*/
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
@@ -237,9 +243,10 @@ function ldExtractNode(data: unknown): JsonLdResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Returns the first JSON-LD node in `html` that has a title or an image,
|
||||
* looking through `@graph` and arrays; empty when there is none. An image may
|
||||
* be a URL, a list of them or an `ImageObject`. Blocks that aren't valid JSON
|
||||
* are skipped.
|
||||
*/
|
||||
export function extractJsonLd(html: string): JsonLdResult {
|
||||
const pattern =
|
||||
@@ -249,14 +256,15 @@ export function extractJsonLd(html: string): JsonLdResult {
|
||||
try {
|
||||
const result = ldExtractNode(JSON.parse(match[1]));
|
||||
if (result.title || result.thumbnailUrl) return result;
|
||||
} catch { /* invalid JSON — skip */ }
|
||||
} catch { /* invalid JSON: try the next block */ }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the absolute URL of the first `<img>` whose declared width and height
|
||||
* are both at least `minSize` pixels; an image without them doesn't count.
|
||||
* Data URIs are skipped.
|
||||
*/
|
||||
export function extractLargeImage(
|
||||
html: string,
|
||||
@@ -283,13 +291,12 @@ export function extractLargeImage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the absolute URL of the page's largest icon, among its `icon` and
|
||||
* `apple-touch-icon` links, by declared `sizes`; the first one when none
|
||||
* declares a size. The generic provider stores it as the preview's
|
||||
* `faviconUrl`, which the thumbnail placeholder draws.
|
||||
*
|
||||
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
|
||||
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
|
||||
* whatever size the placeholder renders them at.
|
||||
* An SVG icon wins over any size: it declares none, yet scales to any.
|
||||
*/
|
||||
export function extractBestIcon(
|
||||
html: string,
|
||||
@@ -330,7 +337,7 @@ export function extractBestIcon(
|
||||
return candidates[0].href;
|
||||
}
|
||||
|
||||
/** Filename tokens that mark an image as UI chrome rather than content. */
|
||||
/** Filename words that mark an image as page furniture rather than content. */
|
||||
const UI_IMAGE_KEYWORDS = new Set([
|
||||
"logo",
|
||||
"icon",
|
||||
@@ -350,24 +357,22 @@ const UI_IMAGE_KEYWORDS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Checks whether `src` looks like UI chrome: an SVG, or a filename with one of
|
||||
* the {@link UI_IMAGE_KEYWORDS}.
|
||||
* Checks whether `src` looks like page furniture: an SVG, or a filename with
|
||||
* one of the {@link UI_IMAGE_KEYWORDS} between its `-`, `_` or `.`.
|
||||
*/
|
||||
function isUiImage(src: string): boolean {
|
||||
if (/\.svg(\?|$)/i.test(src)) return true;
|
||||
const filename = src.split("?")[0].split("/").pop() ?? "";
|
||||
const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension
|
||||
// Split on common filename separators (-, _, .) and check each token
|
||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||
return baseName.toLowerCase().split(/[-_.]/).some((t) =>
|
||||
UI_IMAGE_KEYWORDS.has(t)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the absolute URL of the first `<img>` that looks like content rather
|
||||
* than a logo, icon or other page furniture. Data URIs are skipped. The generic
|
||||
* provider's last resort for a thumbnail.
|
||||
*/
|
||||
export function extractFirstContentImage(
|
||||
html: string,
|
||||
@@ -388,36 +393,3 @@ export function extractFirstContentImage(
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `href` of the first `<link rel="…">` whose rel contains
|
||||
* `relFragment`, resolved to an absolute URL using `baseUrl`.
|
||||
*/
|
||||
export function extractLinkHref(
|
||||
html: string,
|
||||
relFragment: string,
|
||||
baseUrl: string,
|
||||
): string | undefined {
|
||||
const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`,
|
||||
"i",
|
||||
),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) {
|
||||
try {
|
||||
return new URL(match[1], baseUrl).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* The image proxy's fetch and cache: remote thumbnails are served through
|
||||
* this site, so viewers' browsers never contact the hosts.
|
||||
* Fetches and caches the remote images `GET /api/proxy-image` serves. The
|
||||
* frontend routes a thumbnail through it when the image is on plain http,
|
||||
* which an https page would block, or when loading it directly failed, often
|
||||
* to hotlink protection.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from "./fetch.ts";
|
||||
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
|
||||
|
||||
/** An image fetched for the proxy. */
|
||||
/**
|
||||
* Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends
|
||||
* it back under `contentType`.
|
||||
*/
|
||||
export interface ProxiedImage {
|
||||
/** The image's bytes. */
|
||||
bytes: Uint8Array;
|
||||
/** The image's MIME type, as its host sent it. */
|
||||
/** One of the raster types, without parameters. */
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
@@ -26,14 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
"image/vnd.microsoft.icon",
|
||||
]);
|
||||
|
||||
/** 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.
|
||||
* Proxied images, by URL, since every viewer of a feed asks for the same
|
||||
* thumbnails. The least recently used go first, and the Map's order is that
|
||||
* of use.
|
||||
*/
|
||||
const imageCache = new Map<
|
||||
string,
|
||||
@@ -51,7 +53,7 @@ function readImageCache(
|
||||
imageCacheBytes -= hit.bytes.byteLength;
|
||||
return undefined;
|
||||
}
|
||||
imageCache.set(url, hit); // most recently used
|
||||
imageCache.set(url, hit); // Moves it to the end: most recently used.
|
||||
return hit;
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ function writeImageCache(
|
||||
imageCacheBytes += bytes.byteLength;
|
||||
}
|
||||
|
||||
/** Forgets every proxied image (for the tests). */
|
||||
/** Empties the cache, for the tests' reset between cases. */
|
||||
export function clearImageCache(): void {
|
||||
imageCache.clear();
|
||||
imageCacheBytes = 0;
|
||||
@@ -85,9 +87,12 @@ export function clearImageCache(): void {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* the host answers with an error, or with anything but a raster image of 5 MB
|
||||
* at most. The route
|
||||
* checks `url` with `isValidHttpUrl` first.
|
||||
*
|
||||
* @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}).
|
||||
* @throws {Error} When the host can't be reached, as
|
||||
* {@link fetchWithTimeout} reports it.
|
||||
*/
|
||||
export async function fetchProxiedImage(
|
||||
url: string,
|
||||
@@ -96,9 +101,12 @@ export async function fetchProxiedImage(
|
||||
if (cached) return cached;
|
||||
|
||||
const res = await fetchWithTimeout(url, 8000);
|
||||
// Raster formats only. The bytes go out under this site's origin, so an
|
||||
// SVG (which can carry script) or anything else a browser might execute
|
||||
// would run with the app's origin if opened directly.
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
// Raster formats only: the bytes go out under this site's origin, where an
|
||||
// SVG, which can carry script, would run if opened directly.
|
||||
const contentType = (res.headers.get("content-type") ?? "")
|
||||
.split(";")[0].trim().toLowerCase();
|
||||
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
|
||||
@@ -106,14 +114,15 @@ export async function fetchProxiedImage(
|
||||
return null;
|
||||
}
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
||||
if (contentLength > MAX_SIZE) {
|
||||
await res.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
// Without a Content-Length, the cap is found by reading one byte past it.
|
||||
const bytes = await readBytesCapped(res, MAX_SIZE + 1);
|
||||
if (bytes.length > MAX_SIZE) return null;
|
||||
|
||||
writeImageCache(url, bytes, contentType);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The Bandcamp link provider: previews from a release page's OG tags.
|
||||
* Previews Bandcamp links from their page's OG tags, for
|
||||
* `rich-content-service.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,16 +9,19 @@ import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts";
|
||||
|
||||
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||
|
||||
/** The provider for bandcamp.com pages, with the embed player from `og:video`. */
|
||||
/**
|
||||
* Claims bandcamp.com and its subdomains, where artists have their pages. Takes
|
||||
* the embed player's URL from the page's `og:video`. The native player fetches the page's streams
|
||||
* itself at play time, through `GET /api/bandcamp/tracks`.
|
||||
*/
|
||||
export const bandcampProvider: RichContentProvider = {
|
||||
name: "bandcamp",
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
return BANDCAMP_REGEX.test(new URL(url).hostname);
|
||||
return BANDCAMP_HOST_RE.test(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The fallback link provider, for any page no other provider claims.
|
||||
* Previews any page no other provider claims, for `rich-content-service.ts`,
|
||||
* from whatever metadata its HTML carries.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -21,15 +22,16 @@ import {
|
||||
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.
|
||||
* Matches every URL, so it comes last in the service's list. Each field falls
|
||||
* back from OG tags to Twitter tags, JSON-LD and the markup itself. The icon
|
||||
* and brand color feed the placeholder the frontend draws when there is no
|
||||
* thumbnail.
|
||||
*/
|
||||
export const genericProvider: RichContentProvider = {
|
||||
name: "generic",
|
||||
|
||||
matches(_url: string): boolean {
|
||||
return true; // fallback — always matches
|
||||
return true;
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
@@ -44,9 +46,9 @@ export const genericProvider: RichContentProvider = {
|
||||
const html = await readTextCapped(res);
|
||||
const ld = extractJsonLd(html);
|
||||
|
||||
// If og:url is present but points to a different page (e.g. the homepage),
|
||||
// the og: block is a site-level fallback, not page-specific metadata.
|
||||
// In that case skip og:title and og:image so page-level signals win.
|
||||
// An og:url naming another page (often the home page) means the OG tags
|
||||
// are the site's defaults, not this page's: its title and image are
|
||||
// skipped.
|
||||
const ogUrl = extractOgTag(html, "url");
|
||||
const useOg = !ogUrl || (() => {
|
||||
try {
|
||||
@@ -58,36 +60,29 @@ export const genericProvider: RichContentProvider = {
|
||||
}
|
||||
})();
|
||||
|
||||
// Title: og:title (page-matched) → twitter:title → JSON-LD → <title>
|
||||
const title = (useOg ? extractOgTag(html, "title") : undefined) ??
|
||||
extractMetaName(html, "twitter:title") ??
|
||||
ld.title ??
|
||||
extractPageTitle(html);
|
||||
|
||||
// Site name: og:site_name → hostname
|
||||
const siteName = extractOgTag(html, "site_name") ??
|
||||
new URL(url).hostname.replace(/^www\./, "");
|
||||
|
||||
// Description: og:description → twitter:description → JSON-LD → <meta name="description">
|
||||
const description = extractOgTag(html, "description") ??
|
||||
extractMetaName(html, "twitter:description") ??
|
||||
ld.description ??
|
||||
extractMetaName(html, "description");
|
||||
|
||||
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> →
|
||||
// first content <img>. The chain deliberately stops there: a favicon is not
|
||||
// artwork, and pretending otherwise means every art-less page gets a 16×16
|
||||
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
|
||||
// and the frontend draws a generated placeholder instead.
|
||||
// Never the favicon: a 16×16 icon cropped into a thumbnail looks broken,
|
||||
// and the placeholder drawn instead shows the icon properly.
|
||||
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
|
||||
extractMetaName(html, "twitter:image") ??
|
||||
ld.thumbnailUrl ??
|
||||
extractLargeImage(html, url) ??
|
||||
extractFirstContentImage(html, url);
|
||||
|
||||
// Icon and brand color are independent facts about the page, so they're
|
||||
// collected whether or not there's artwork. `/favicon.ico` is a guess; when
|
||||
// it 404s the placeholder falls back to the site's initial.
|
||||
// `/favicon.ico` is a guess. When it doesn't load, the placeholder shows
|
||||
// the site's initial.
|
||||
const faviconUrl = extractBestIcon(html, url) ??
|
||||
`${new URL(url).origin}/favicon.ico`;
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* The interface every link provider implements.
|
||||
* Declares what a link provider implements. `rich-content-service.ts` asks its
|
||||
* providers in turn and fetches a link's preview from the first that matches.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
|
||||
/** Metadata for the links one kind of site serves. */
|
||||
/**
|
||||
* Implemented by each file of `remote/providers/`, and listed in priority order
|
||||
* by `rich-content-service.ts`.
|
||||
*/
|
||||
export interface RichContentProvider {
|
||||
/** The provider's name, for logs. */
|
||||
name: string;
|
||||
/** Checks whether this provider handles `url`. */
|
||||
/** Called on a URL that `isValidHttpUrl` accepted; never throws. */
|
||||
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.
|
||||
* Returns the preview of `url`, which ends up in the dump's `rich_content` or
|
||||
* the create form's preview. It rejects when the page can't be fetched, and
|
||||
* the service then falls back to a stub. A page that isn't HTML yields a
|
||||
* preview without metadata.
|
||||
*/
|
||||
fetch(url: string): Promise<RichContent>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The link provider for this site's own pages, read from the database
|
||||
* instead of fetched.
|
||||
* Previews links to this site's own dump, user and playlist pages from the
|
||||
* database, for `rich-content-service.ts`. No request goes out, so this works
|
||||
* even when the site runs on a private host that `fetchWithTimeout` refuses.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,6 +11,7 @@ import type { RichContentProvider } from "./provider.ts";
|
||||
import { getDump } from "../../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../../services/user-service.ts";
|
||||
import { getPlaylistById } from "../../services/playlist-service.ts";
|
||||
import { APIException } from "../../lib/api-exception.ts";
|
||||
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||
import { isSelfUrl } from "../../lib/self-url.ts";
|
||||
|
||||
@@ -18,8 +20,8 @@ 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.
|
||||
* Gives a link to this site without a thumbnail a placeholder in the site's
|
||||
* icon and color.
|
||||
*/
|
||||
const SELF_BRANDING = {
|
||||
siteName: "gerbeur",
|
||||
@@ -28,9 +30,10 @@ const SELF_BRANDING = {
|
||||
} 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.
|
||||
* Comes before the generic provider, and claims what `isSelfUrl` accepts. Its
|
||||
* `fetch` rejects with a 404 `APIException` for a missing page, a private dump
|
||||
* or a private playlist: the preview is stored in the linking dump, which
|
||||
* others may see.
|
||||
*/
|
||||
export const selfProvider: RichContentProvider = {
|
||||
name: "self",
|
||||
@@ -84,6 +87,9 @@ export const selfProvider: RichContentProvider = {
|
||||
const playlistMatch = pathname.match(PLAYLIST_RE);
|
||||
if (playlistMatch) {
|
||||
const playlist = getPlaylistById(playlistMatch[1]);
|
||||
if (!playlist.isPublic) {
|
||||
throw new APIException(404, "Playlist not found");
|
||||
}
|
||||
const thumbnailUrl = playlist.imageMime
|
||||
? `${PUBLIC_URL}/api/playlists/${playlist.id}/image`
|
||||
: undefined;
|
||||
@@ -97,7 +103,7 @@ export const selfProvider: RichContentProvider = {
|
||||
});
|
||||
}
|
||||
|
||||
// Should not reach here if matches() is correct
|
||||
// Unreachable: isSelfUrl accepts only the three paths above.
|
||||
return Promise.resolve({ type: "generic", url });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The SoundCloud link provider: previews from a track page's OG tags.
|
||||
* Previews SoundCloud links from their page's OG tags, for
|
||||
* `rich-content-service.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,7 +10,10 @@ 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. */
|
||||
/**
|
||||
* Claims soundcloud.com only, not www. or m.soundcloud.com, and builds the
|
||||
* embed player's URL from the link itself.
|
||||
*/
|
||||
export const soundcloudProvider: RichContentProvider = {
|
||||
name: "soundcloud",
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The YouTube link provider: videos, playlists and channels.
|
||||
* Previews YouTube videos, playlists and channels, for
|
||||
* `rich-content-service.ts`. Videos and playlists are read from YouTube's
|
||||
* oEmbed endpoint, channels from their page's OG tags.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,43 +10,32 @@ import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts";
|
||||
|
||||
/** Returns the id of the video a youtu.be or youtube.com URL points at. */
|
||||
/** Returns the id of the video `url` points at, in any shape YouTube uses. */
|
||||
function extractVideoId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "youtu.be") {
|
||||
return u.pathname.slice(1).split("/")[0] || null;
|
||||
}
|
||||
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
|
||||
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
|
||||
return u.searchParams.get("v");
|
||||
}
|
||||
if (
|
||||
u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")
|
||||
) {
|
||||
return u.pathname.split("/")[2] || null;
|
||||
}
|
||||
}
|
||||
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||
return youtubeVideoId(host, u.pathname, u.searchParams);
|
||||
} catch {
|
||||
// invalid URL
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns the `list` parameter of a URL: the playlist it plays. */
|
||||
/** Returns the `list` parameter of a YouTube URL, and null for any other. */
|
||||
function extractPlaylistId(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).searchParams.get("list");
|
||||
const u = new URL(url);
|
||||
return youtubeHost(u) ? u.searchParams.get("list") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the start time of a YouTube URL in seconds, from `t` (`90`, `90s`,
|
||||
* `1h2m3s`) or `start`; null when there is none that parses.
|
||||
*/
|
||||
function extractStartSeconds(url: string): number | null {
|
||||
let raw: string | null;
|
||||
@@ -66,21 +57,14 @@ function extractStartSeconds(url: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or
|
||||
* /user/name.
|
||||
* Returns the path of a YouTube channel URL (`/channel/UC…`, `/@handle`,
|
||||
* `/c/name` or `/user/name`); null for any other URL.
|
||||
*/
|
||||
function extractChannelPath(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
|
||||
if (
|
||||
u.pathname.startsWith("/channel/") ||
|
||||
u.pathname.startsWith("/@") ||
|
||||
u.pathname.startsWith("/c/") ||
|
||||
u.pathname.startsWith("/user/")
|
||||
) {
|
||||
return u.pathname;
|
||||
}
|
||||
if (youtubeHost(u) && /^\/(channel\/|@|c\/|user\/)/.test(u.pathname)) {
|
||||
return u.pathname;
|
||||
}
|
||||
} catch {
|
||||
// invalid URL
|
||||
@@ -90,7 +74,7 @@ function extractChannelPath(url: string): string | null {
|
||||
|
||||
/**
|
||||
* Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint;
|
||||
* empty when that fails.
|
||||
* empty when that fails, since the preview can do without them.
|
||||
*/
|
||||
async function fetchOEmbed(
|
||||
url: string,
|
||||
@@ -109,14 +93,16 @@ async function fetchOEmbed(
|
||||
}
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
// oembed failed — carry on
|
||||
// YouTube unreachable, or its answer not JSON.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider for YouTube videos (with their playlist and start time in the embed),
|
||||
* playlists (as a `videoseries` embed) and channels (not embeddable).
|
||||
* Comes first in the service's list. A video's embed keeps the link's playlist
|
||||
* and start time, a playlist embeds as a `videoseries`, and a channel gets no
|
||||
* embed. Its `fetch` never rejects: whatever YouTube doesn't answer is left
|
||||
* out.
|
||||
*/
|
||||
export const youtubeProvider: RichContentProvider = {
|
||||
name: "youtube",
|
||||
@@ -134,9 +120,8 @@ export const youtubeProvider: RichContentProvider = {
|
||||
const listId = extractPlaylistId(url);
|
||||
const channelPath = extractChannelPath(url);
|
||||
|
||||
// ── Channel ───────────────────────────────────────────────────────────────
|
||||
if (channelPath && !videoId) {
|
||||
// oEmbed doesn't support channel URLs — scrape og:image from the page instead
|
||||
// oEmbed doesn't take channel URLs.
|
||||
let title: string | undefined;
|
||||
let thumbnailUrl: string | undefined;
|
||||
try {
|
||||
@@ -149,7 +134,7 @@ export const youtubeProvider: RichContentProvider = {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
} catch {
|
||||
// scrape failed — carry on with undefined values
|
||||
// YouTube unreachable: the preview goes without.
|
||||
}
|
||||
return {
|
||||
type: "youtube",
|
||||
@@ -157,11 +142,9 @@ export const youtubeProvider: RichContentProvider = {
|
||||
url,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
// channels are not embeddable as a player
|
||||
};
|
||||
}
|
||||
|
||||
// ── Playlist (no specific video) ──────────────────────────────────────────
|
||||
if (listId && !videoId) {
|
||||
const { title, thumbnailUrl } = await fetchOEmbed(url);
|
||||
return {
|
||||
@@ -175,7 +158,7 @@ export const youtubeProvider: RichContentProvider = {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Video (with optional playlist context) ────────────────────────────────
|
||||
// Built, not taken from oEmbed, so it is there even when oEmbed fails.
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const { title } = await fetchOEmbed(url);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Guards for server-side fetches of user-supplied URLs (link previews, the
|
||||
* 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.
|
||||
* Tells private addresses from public ones, so that a user-supplied URL can't
|
||||
* make the server fetch what only it can reach: loopback, the LAN, container
|
||||
* networks or the cloud metadata endpoint at 169.254.169.254. `fetch.ts` checks
|
||||
* every hop with it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -83,8 +83,9 @@ function isPrivateIPv6(b: number[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `ip` is an IP literal outside the public internet. Non-IPs
|
||||
* are false.
|
||||
* Checks whether `ip` is an IPv4 or IPv6 address outside the public internet.
|
||||
* An IPv4 address carried in IPv6 is judged as IPv4. Anything that isn't an IP
|
||||
* address is false, so a hostname must be resolved first.
|
||||
*/
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = parseIPv4(ip);
|
||||
@@ -106,9 +107,9 @@ function bareHostname(url: URL): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether the host of `url` is private without a DNS lookup: a private
|
||||
* IP address, or `localhost` and its subdomains. `isValidHttpUrl` uses it to
|
||||
* reject a link early; only {@link resolvePublicAddress} is a real guard.
|
||||
*/
|
||||
export function isPrivateHostname(url: URL): boolean {
|
||||
const host = bareHostname(url).toLowerCase();
|
||||
@@ -117,13 +118,13 @@ export function isPrivateHostname(url: URL): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `url`'s host and returns one of its addresses, or throws if the
|
||||
* host is private or any address it resolves to is (a name with both a public
|
||||
* and a private record is refused outright, since the fetch may pick either).
|
||||
* Resolves the host of `url` and returns one of its addresses, all of which
|
||||
* must be public: the fetch may pick any of them. `fetchWithTimeout` calls it
|
||||
* before each hop.
|
||||
*
|
||||
* 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.
|
||||
* Only the curl fallback connects to the address returned. Deno's fetch
|
||||
* resolves the name again, so a DNS server that answers differently the second
|
||||
* time (rebinding) can still get through.
|
||||
*
|
||||
* @throws {Error} When the host is private, can't be resolved, or resolves to
|
||||
* a private address.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* Twemoji glyphs, fetched from the jsDelivr CDN.
|
||||
* Fetches Twemoji glyphs from the jsDelivr CDN, for `lib/site-icons.ts` to
|
||||
* render the favicons from.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The pinned Twemoji release. */
|
||||
/** Pinned, so a new Twemoji release can't change the icons unasked. */
|
||||
const TWEMOJI_VERSION = "16.0.1";
|
||||
|
||||
/** Fetched glyphs by codepoint, so repeated runs don't re-hit the network. */
|
||||
/**
|
||||
* Glyphs by codepoint. It only lives as long as the process, and the `icons`
|
||||
* task renders one emoji per process.
|
||||
*/
|
||||
const glyphCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named
|
||||
* `cp` (see `emojiToCodepoint` in lib/site-icons.ts).
|
||||
* Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from
|
||||
* `emojiToCodepoint`. `emoji` only serves the error message. It fetches with
|
||||
* plain `fetch`, since the host is fixed, and gives up after 15 s.
|
||||
*
|
||||
* @throws {Error} When the glyph can't be fetched.
|
||||
*/
|
||||
@@ -26,7 +31,9 @@ export async function fetchTwemojiSvg(
|
||||
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
// The icons task runs before the server starts, so a stalled CDN
|
||||
// mustn't hold the start up forever.
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
/**
|
||||
* Resolves a YouTube playlist to its tracklist, at play time.
|
||||
* Resolves a YouTube playlist to its entries' titles and lengths, for
|
||||
* `GET /api/youtube/playlist`. The embed plays the list but doesn't expose
|
||||
* them, and the global player needs them to draw a tracklist.
|
||||
*
|
||||
* Playback itself stays in the iframe embed — YouTube plays the list and
|
||||
* advances through it on its own. This only supplies what the embed doesn't
|
||||
* expose: every entry's title and length, so the global player can draw a
|
||||
* tracklist the way it does for a Bandcamp album. The client syncs the
|
||||
* highlighted row and row clicks through the IFrame Player API.
|
||||
*
|
||||
* The data comes from the `ytInitialData` blob on the public playlist page. Its
|
||||
* shape is undocumented and changes, so the parse is best-effort and accepts
|
||||
* 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.
|
||||
* They are read from the `ytInitialData` JSON of the public playlist page,
|
||||
* whose undocumented shape changes, so both the current `lockupViewModel` rows
|
||||
* and the older `playlistVideoRenderer` ones are accepted. Only the first 100
|
||||
* entries are there; the client adds the rest, untitled, as the player reports
|
||||
* them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { readTextCapped } from "./fetch.ts";
|
||||
import type {
|
||||
@@ -24,28 +19,25 @@ import type {
|
||||
YouTubePlaylistTrack,
|
||||
} from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* How long a resolved playlist is cached. Titles and lengths rarely change; a
|
||||
* stale entry only mislabels a row.
|
||||
*/
|
||||
/** Titles and lengths rarely change, and a stale one only mislabels a row. */
|
||||
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** The most playlists cached; the oldest write goes first. */
|
||||
/** The oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
/** Resolved playlists, by list id. */
|
||||
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
|
||||
|
||||
/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */
|
||||
/**
|
||||
* Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else.
|
||||
*/
|
||||
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",
|
||||
"Accept": "text/html",
|
||||
"Accept-Language": "en-US,en;q=0.8",
|
||||
// Pre-answered consent: without it EU requests are redirected to
|
||||
// consent.youtube.com and never see the playlist.
|
||||
// Declines the consent prompt, which EU requests are otherwise redirected
|
||||
// to instead of the playlist.
|
||||
"Cookie": "SOCS=CAI",
|
||||
};
|
||||
|
||||
@@ -55,7 +47,7 @@ function parseClock(text: string): number | undefined {
|
||||
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
|
||||
}
|
||||
|
||||
/** Collects, depth-first, every value stored under `key` into `out`. */
|
||||
/** 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);
|
||||
@@ -74,10 +66,7 @@ function firstString(node: unknown, key: string): string | undefined {
|
||||
return found.find((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value at `path` in parsed JSON; undefined wherever a step is
|
||||
* missing.
|
||||
*/
|
||||
/** Returns the value at `path` in `node`; undefined when a step is missing. */
|
||||
function at(node: unknown, ...path: (string | number)[]): unknown {
|
||||
let cur = node;
|
||||
for (const key of path) {
|
||||
@@ -124,8 +113,8 @@ 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.
|
||||
* Returns the playlist `listId` as read from its page `html`; null when the
|
||||
* page has no `ytInitialData` or no track in it.
|
||||
*/
|
||||
export function parsePlaylistPage(
|
||||
html: string,
|
||||
@@ -148,8 +137,7 @@ export function parsePlaylistPage(
|
||||
const legacy: unknown[] = [];
|
||||
collect(at(data, "contents"), "playlistVideoRenderer", legacy);
|
||||
|
||||
// A video can appear in a playlist twice; both rows are kept, so the list
|
||||
// stays positional and matches what the embed plays.
|
||||
// A video listed twice keeps both rows, so that row n is the embed's entry n.
|
||||
const tracks = [
|
||||
...lockups.map(fromLockup),
|
||||
...legacy.map(fromLegacyRenderer),
|
||||
@@ -164,7 +152,8 @@ export function parsePlaylistPage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist `listId` and returns its tracklist, cached.
|
||||
* Returns the tracklist of the playlist `listId`, from the cache when it was
|
||||
* resolved in the last 6 hours.
|
||||
*
|
||||
* @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
|
||||
@@ -174,11 +163,7 @@ export async function resolveYouTubePlaylist(
|
||||
listId: string,
|
||||
): Promise<YouTubePlaylist> {
|
||||
if (!LIST_ID_RE.test(listId)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid playlist ID",
|
||||
);
|
||||
throw new APIException(400, "Invalid playlist ID");
|
||||
}
|
||||
|
||||
const hit = cache.get(listId);
|
||||
@@ -189,39 +174,28 @@ export async function resolveYouTubePlaylist(
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
let html: string;
|
||||
try {
|
||||
// youtube.com needs no address check, and fetchWithTimeout can't send the
|
||||
// consent cookie.
|
||||
const res = await fetch(
|
||||
`https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`,
|
||||
{ headers: HEADERS, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
`YouTube returned ${res.status}`,
|
||||
);
|
||||
throw new APIException(502, `YouTube returned ${res.status}`);
|
||||
}
|
||||
html = await readTextCapped(res);
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Could not reach YouTube",
|
||||
);
|
||||
throw new APIException(502, "Could not reach YouTube");
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const parsed = parsePlaylistPage(html, listId);
|
||||
if (!parsed) {
|
||||
// Private, deleted, or auto-generated mixes (RD…) that have no playlist
|
||||
// page — the client just shows the embed without a tracklist.
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No tracks on this playlist",
|
||||
);
|
||||
// The client then shows the embed without a tracklist.
|
||||
throw new APIException(404, "No tracks on this playlist");
|
||||
}
|
||||
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* The attachment routes, for images pasted into Markdown bodies.
|
||||
* Stores and serves the images pasted or dropped into the text editor, which
|
||||
* puts each one in the body as a link to its `GET` URL.
|
||||
*
|
||||
* - `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.
|
||||
* most 5 MB) and answers its id.
|
||||
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
|
||||
* private dump or playlist, or in a comment on a private dump, needs its
|
||||
* owner's media `token`. Serves the image.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,35 +15,32 @@ import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
createAttachment,
|
||||
getAttachment,
|
||||
getAttachmentAccess,
|
||||
} from "../services/attachment-service.ts";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { AttachmentResponse } from "../../shared/dump.ts";
|
||||
import {
|
||||
ATTACHMENTS_DIR,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { serveUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { ATTACHMENTS_DIR } from "../config.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
throw new APIException(400, "Expected multipart/form-data");
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
const file = body.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file");
|
||||
throw new APIException(400, "Missing file");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
@@ -68,18 +67,30 @@ router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
router.get("/api/attachments/:attachmentId", async (ctx) => {
|
||||
const { attachmentId } = ctx.params;
|
||||
|
||||
let attachment;
|
||||
let access;
|
||||
try {
|
||||
attachment = getAttachment(attachmentId);
|
||||
access = getAttachmentAccess(attachmentId);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
if (access.ownerId) {
|
||||
// An <img> can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
if (viewerId !== access.ownerId) {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await serveUploadedFile(
|
||||
ctx,
|
||||
`${ATTACHMENTS_DIR}/${attachmentId}`,
|
||||
attachment.mime,
|
||||
access.mime,
|
||||
3600,
|
||||
!!access.ownerId,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* The avatar routes.
|
||||
* Serves the avatar endpoints. The profile page uploads the avatar, and the
|
||||
* client's `Avatar` component loads it, with the user's `updatedAt` as a
|
||||
* cache-busting version.
|
||||
*
|
||||
* - `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.
|
||||
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
|
||||
* caller's user.
|
||||
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
|
||||
* one.
|
||||
*
|
||||
@@ -16,56 +19,49 @@ import {
|
||||
broadcastUserUpdated,
|
||||
updateClientAvatar,
|
||||
} from "../services/ws-service.ts";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { SelfUser } from "../../shared/user.ts";
|
||||
import { toPublicUser, toSelfUser } from "../model/user.ts";
|
||||
import {
|
||||
AVATARS_DIR,
|
||||
replaceUploadedFile,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { AVATARS_DIR } from "../config.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
const authPayload = ctx.state.user;
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
throw new APIException(400, "Expected multipart/form-data");
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
const file = body.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
const filePath = `${AVATARS_DIR}/${authPayload.userId}`;
|
||||
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||
await Deno.writeFile(filePath, data);
|
||||
try {
|
||||
updateUserAvatar(authPayload.userId, mime);
|
||||
} catch (err) {
|
||||
// DB write failed — clean up the orphaned file
|
||||
await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
// Written before the record announces it, or clients would fetch it too
|
||||
// early.
|
||||
await replaceUploadedFile(`${AVATARS_DIR}/${authPayload.userId}`, data);
|
||||
updateUserAvatar(authPayload.userId, mime);
|
||||
updateClientAvatar(authPayload.userId, mime);
|
||||
|
||||
const user = getUserById(authPayload.userId);
|
||||
// Let every client refresh this user's avatar wherever it's shown (feeds,
|
||||
// comments, OP, profiles) — not just the presence row. updated_at, bumped on
|
||||
// the avatar write, doubles as the cache-busting version.
|
||||
// Presence alone would update only the online list; `user_updated` reaches
|
||||
// every avatar on screen, and its bumped `updatedAt` busts their cache.
|
||||
broadcastUserUpdated(toPublicUser(user));
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* The Bandcamp route, for the native player.
|
||||
* Serves the Bandcamp release the native player plays, resolved from its page
|
||||
* when playback starts. On any failure the player falls back to the embed.
|
||||
*
|
||||
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page
|
||||
* to its streamable tracks, at play time; `force=1` bypasses the cache.
|
||||
* - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's
|
||||
* tracks and their stream URLs; `force=1` skips the server's cache.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,13 +13,14 @@ import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
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".
|
||||
// Public: it reads a public page and answers the URLs that page gives any
|
||||
// visitor.
|
||||
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const force = ctx.request.url.searchParams.get("force") === "1";
|
||||
@@ -28,8 +30,8 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// Private: the signed URLs in the body are per-fetch, so they must not be
|
||||
// held in a shared cache. Well inside the 24h signature life either way.
|
||||
// Not in a shared cache: the signed URLs belong to one page fetch. An hour
|
||||
// is well inside their 24 hours.
|
||||
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -16,11 +16,7 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIResponse,
|
||||
type PaginatedData,
|
||||
} from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
@@ -41,7 +37,10 @@ import {
|
||||
updateCategory,
|
||||
} from "../services/category-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/categories" });
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
@@ -84,11 +83,7 @@ router.post(
|
||||
async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid category data",
|
||||
);
|
||||
throw new APIException(400, "Invalid category data");
|
||||
}
|
||||
const category = createCategory(body);
|
||||
const responseBody: APIResponse<Category> = {
|
||||
@@ -107,11 +102,7 @@ router.put(
|
||||
async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid category data",
|
||||
);
|
||||
throw new APIException(400, "Invalid category data");
|
||||
}
|
||||
const category = updateCategory(ctx.params.id, body);
|
||||
const responseBody: APIResponse<Category> = {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* The chat history route; messages are posted and edited over the socket (see
|
||||
* routes/ws.ts).
|
||||
* Serves the chat's history to the chatbox (`ChatModal`). Messages are sent,
|
||||
* edited and deleted over the socket instead (`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.
|
||||
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=`, signed in.
|
||||
* Returns messages oldest first, at most `limit` (50 by default, 100 at
|
||||
* most). With no cursor, it returns the latest. With `before`, it returns
|
||||
* the page just older than that cursor, for "load older". With `after`, it
|
||||
* returns what follows it, for the resync after a reconnect. A cursor is a
|
||||
* message's ISO `created_at`, and its id breaks ties. Without `afterId`,
|
||||
* messages at the `after` timestamp itself are included.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
getRecentChatMessages,
|
||||
} from "../services/chat-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/chat" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The comment routes. A private dump's comments exist only for its poster, and
|
||||
* changes to them aren't broadcast.
|
||||
* Serves the comments on dumps. The comments of a private dump 404 for anyone
|
||||
* but its poster, and changes to them aren't broadcast. Comments are otherwise
|
||||
* broadcast over the WebSocket as they are posted, edited and deleted.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
||||
* on a dump, oldest first.
|
||||
@@ -17,11 +18,7 @@
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIResponse,
|
||||
type PaginatedData,
|
||||
} from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
import {
|
||||
@@ -49,7 +46,10 @@ import {
|
||||
broadcastCommentUpdated,
|
||||
} from "../services/ws-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api" });
|
||||
|
||||
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
@@ -68,11 +68,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid comment data",
|
||||
);
|
||||
throw new APIException(400, "Invalid comment data");
|
||||
}
|
||||
const comment = createComment(
|
||||
dump.id,
|
||||
@@ -91,11 +87,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid comment data",
|
||||
);
|
||||
throw new APIException(400, "Invalid comment data");
|
||||
}
|
||||
const { comment, isPrivate } = updateComment(
|
||||
ctx.params.commentId,
|
||||
@@ -111,7 +103,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dumpId = getCommentDumpId(ctx.params.commentId);
|
||||
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
|
||||
getDump(dumpId, requestingUserId); // 404s unless the caller may see the dump.
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = getCommentLikers(
|
||||
ctx.params.commentId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
|
||||
* for its poster.
|
||||
* Posts, reads, edits and deletes dumps, for the index feed, the dump page, its
|
||||
* edit page and the create form. `:dumpId` takes an id or a slug. A private
|
||||
* dump answers 404 to anyone but its poster, moderators included.
|
||||
*
|
||||
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
|
||||
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
|
||||
@@ -14,9 +15,10 @@
|
||||
* - `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.
|
||||
* dumps the caller may see, newest first.
|
||||
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces a file dump's upload (multipart).
|
||||
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
|
||||
* title, categories and visibility (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.
|
||||
@@ -35,11 +37,7 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIResponse,
|
||||
type PaginatedData,
|
||||
} from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
|
||||
import {
|
||||
@@ -54,7 +52,7 @@ import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { validateImageUpload } from "../lib/upload.ts";
|
||||
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import {
|
||||
createFileDump,
|
||||
@@ -73,16 +71,18 @@ import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
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.
|
||||
// Each handler resolves `:dumpId` with getDump, which checks privacy and takes
|
||||
// a slug, and passes `dump.id` on: the services' writes match on the id alone.
|
||||
|
||||
/**
|
||||
* Reads the category ids that arrive on the multipart create path as a
|
||||
* JSON-encoded string field; empty when it is missing or malformed.
|
||||
* Parses a multipart form's `categoryIds`, a JSON array sent as a string. A
|
||||
* missing or malformed value counts as no category.
|
||||
*/
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
@@ -113,11 +113,7 @@ router.post(
|
||||
const isPrivate = formData.get("isPrivate") === "true";
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"A file is required",
|
||||
);
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
dump = await createFileDump(
|
||||
@@ -132,11 +128,7 @@ router.post(
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isCreateUrlDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid dump data",
|
||||
);
|
||||
throw new APIException(400, "Invalid dump data");
|
||||
}
|
||||
|
||||
dump = await createUrlDump(body, userId);
|
||||
@@ -191,7 +183,6 @@ router.get("/:dumpId/related", async (ctx) => {
|
||||
|
||||
router.get("/:dumpId/playlists", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
// Resolve through getDump so private dumps 404 for anyone but their owner.
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const playlists = getPlaylistsForDump(dump.id, requestingUserId);
|
||||
const responseBody: APIResponse<Playlist[]> = {
|
||||
@@ -218,11 +209,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
@@ -231,11 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const title = formData.get("title");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"A file is required",
|
||||
);
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
@@ -260,21 +243,13 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
422,
|
||||
"Erroneous user input",
|
||||
);
|
||||
throw new APIException(400, "Erroneous user input");
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await updateDump(dump.id, body);
|
||||
@@ -288,37 +263,23 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Missing file field",
|
||||
);
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// DB update first (resolves slug → id), then file write.
|
||||
// Written before setDumpThumbnail announces it, or clients would fetch it
|
||||
// too early.
|
||||
await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data);
|
||||
const updatedDump = setDumpThumbnail(dump.id, mime);
|
||||
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
try {
|
||||
await Deno.writeFile(filePath, data);
|
||||
} catch (err) {
|
||||
await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
@@ -330,11 +291,7 @@ router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await removeDumpThumbnail(dump.id);
|
||||
@@ -348,11 +305,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await refreshDumpMetadata(dump.id);
|
||||
@@ -366,11 +319,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Not authorized to delete dump",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to delete dump");
|
||||
}
|
||||
|
||||
await deleteDump(dump.id);
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
/**
|
||||
* The route serving file dumps' uploads.
|
||||
* Serves file dumps' uploads to the players, viewers and download links of the
|
||||
* frontend, which builds the URL with `dumpFileUrl`.
|
||||
*
|
||||
* - `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.
|
||||
* - `GET /api/files/:dumpId?v=&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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/files" });
|
||||
|
||||
/**
|
||||
* Builds an RFC 6266 `Content-Disposition` value that is always a valid
|
||||
* ByteString. `headers.set` throws a TypeError if the value contains any
|
||||
* character > U+00FF, so a filename with an emoji / accent / CJK character
|
||||
* (e.g. an uploaded file) would otherwise crash the response. We provide an
|
||||
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
||||
* full UTF-8 name for modern browsers.
|
||||
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
||||
* throws on any character above U+00FF, so `filename` gets an ASCII copy of the
|
||||
* name and `filename*` (RFC 5987) the full one, percent-encoded.
|
||||
*/
|
||||
function contentDisposition(
|
||||
type: "inline" | "attachment",
|
||||
@@ -40,17 +40,16 @@ function contentDisposition(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Matches the types served inline. An upload's type is whatever the uploader's
|
||||
* browser claimed, and the file comes from the app's own origin, where the
|
||||
* session token lives. So only types a browser shows without running code in
|
||||
* them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`,
|
||||
* `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and
|
||||
* previews 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());
|
||||
}
|
||||
@@ -58,24 +57,19 @@ function isInlineSafe(mime: string): boolean {
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// Guard against path traversal (UUIDs are safe, but be explicit)
|
||||
// The id names a file on disk: nothing but an id's characters gets through.
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
throw new APIException(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Media elements can't send an Authorization header, so the viewer's token
|
||||
// arrives as a query param (same as the WebSocket route). Needed to authorize
|
||||
// access to private dumps the requester owns.
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No file for this dump",
|
||||
);
|
||||
throw new APIException(404, "No file for this dump");
|
||||
}
|
||||
|
||||
const path = `${DUMPS_DIR}/${dumpId}`;
|
||||
@@ -98,10 +92,9 @@ router.get("/:dumpId", async (ctx) => {
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
|
||||
// The app's URLs carry the file's version (see dumpFileVersion), so one
|
||||
// naming the current version never changes and is cached for good; any
|
||||
// other URL is revalidated against the ETag. A private dump's bytes must not
|
||||
// sit in a shared cache.
|
||||
// A URL naming the file's current version (dumpFileVersion) always serves the
|
||||
// same bytes, so it is cached for good; any other is revalidated against the
|
||||
// ETag. A private dump's bytes must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
@@ -113,7 +106,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
ranges: true,
|
||||
});
|
||||
if (!served) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||
throw new APIException(404, "File not found");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The follow routes, all for what the caller follows.
|
||||
* Serves the endpoints for what the caller follows, and the index's "followed"
|
||||
* tab built from it. What another user follows is served by `routes/users.ts`.
|
||||
*
|
||||
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
|
||||
* playlists they follow.
|
||||
@@ -37,10 +38,9 @@ import {
|
||||
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.
|
||||
* Returns the id of the category the `?category=` slug names, undefined without
|
||||
* one, or "missing" when it names none, so that the feed comes back empty
|
||||
* rather than unfiltered.
|
||||
*/
|
||||
function resolveCategoryFilter(
|
||||
params: URLSearchParams,
|
||||
@@ -50,14 +50,14 @@ function resolveCategoryFilter(
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
}
|
||||
|
||||
/** The feed a filter naming no category yields. */
|
||||
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/follows" });
|
||||
|
||||
// Static routes first to prevent Oak matching "status"/"feed" as a :param
|
||||
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/**
|
||||
* The invite routes.
|
||||
* Serves the invite endpoints. Any signed-in user can invite someone, from
|
||||
* their profile page, and the invite link opens the `/register` page.
|
||||
*
|
||||
* - `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.
|
||||
* - `GET /api/invites/:token` — public. Checks that an invite still works,
|
||||
* before the register page shows its form; 404 when it doesn't.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { InviteResponse } from "../../shared/user.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/invites" });
|
||||
|
||||
router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
const responseBody: APIResponse<InviteResponse> = {
|
||||
success: true,
|
||||
data: { token: createInvite(ctx.state.user.userId) },
|
||||
@@ -31,16 +32,13 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
});
|
||||
|
||||
router.get("/:token", (ctx) => {
|
||||
// A used invite answers 404 too: the page only needs to know it won't work.
|
||||
try {
|
||||
validateInvite(ctx.params.token);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Invalid or expired invite",
|
||||
);
|
||||
throw new APIException(404, "Invalid or expired invite");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
/**
|
||||
* The notification routes, all for the caller's own notifications.
|
||||
* Serves the caller's own notifications to the client's `/notifications`
|
||||
* page, which marks them all read when it opens. New ones arrive over the
|
||||
* WebSocket instead, as `notification_created`.
|
||||
*
|
||||
* - `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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIResponse,
|
||||
type PaginatedData,
|
||||
} from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import type { Notification } from "../../shared/notification.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
getNotificationsForUser,
|
||||
markAllRead,
|
||||
markOneRead,
|
||||
} from "../services/notification-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/notifications" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = getNotificationsForUser(
|
||||
ctx.state.user.userId,
|
||||
@@ -46,20 +41,8 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
});
|
||||
|
||||
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
markAllRead(ctx.state.user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.patch("/:id/read", authMiddleware, (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
if (!user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
markOneRead(ctx.params.id, user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* The playlist routes. `:playlistId` takes an id or a slug.
|
||||
* Serves playlists, their dumps and their covers. `: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.
|
||||
* with the dumps the caller may see in it. A private one 404s but for 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
|
||||
@@ -16,8 +18,8 @@
|
||||
* 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
|
||||
* - `GET /api/playlists/:playlistId/image?token=` — public, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
|
||||
* 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.
|
||||
@@ -27,7 +29,7 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
isCreatePlaylistRequest,
|
||||
@@ -55,12 +57,16 @@ import {
|
||||
updatePlaylist,
|
||||
} from "../services/playlist-service.ts";
|
||||
import {
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
replaceUploadedFile,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { PLAYLIST_IMAGES_DIR } from "../config.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
@@ -79,11 +85,7 @@ router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
const responseBody: APIResponse<Playlist> = {
|
||||
success: true,
|
||||
@@ -105,11 +107,7 @@ router.get("/:playlistId", async (ctx) => {
|
||||
router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
const playlist = updatePlaylist(
|
||||
ctx.params.playlistId,
|
||||
@@ -153,43 +151,27 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
throw new APIException(400, "Expected multipart/form-data");
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
const file = body.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// The file goes in before the row changes. Setting image_mime and bumping
|
||||
// updated_at announces the cover (the `?v=` on its URL, the socket event),
|
||||
// so the bytes must already be there: an earlier fetch would cache the old
|
||||
// cover under the new URL, and a failed write would leave a cover that 404s.
|
||||
// Written beside and renamed over, so the old cover is never half-replaced.
|
||||
// Written before setPlaylistImage announces it, or clients would fetch the
|
||||
// new `?v=` URL too early and cache the old cover under it.
|
||||
const { id } = getEditablePlaylist(
|
||||
ctx.params.playlistId,
|
||||
ctx.state.user.userId,
|
||||
can(ctx.state.user, "playlist:moderate"),
|
||||
);
|
||||
const filePath = `${PLAYLIST_IMAGES_DIR}/${id}`;
|
||||
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
||||
await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true });
|
||||
try {
|
||||
await Deno.writeFile(tmpPath, data);
|
||||
await Deno.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
await Deno.remove(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await replaceUploadedFile(`${PLAYLIST_IMAGES_DIR}/${id}`, data);
|
||||
const playlist = setPlaylistImage(
|
||||
id,
|
||||
mime,
|
||||
@@ -210,7 +192,7 @@ router.get("/:playlistId/image", async (ctx) => {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
// The URL may carry a media token, which a Referer would leak.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
await serveUploadedFile(
|
||||
ctx,
|
||||
@@ -224,11 +206,7 @@ router.get("/:playlistId/image", async (ctx) => {
|
||||
router.put("/:playlistId/order", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isReorderPlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
reorderPlaylist(
|
||||
ctx.params.playlistId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The link preview routes.
|
||||
* Fetches link previews for the create form, and proxies the remote images
|
||||
* that the frontend's thumbnails can't load directly.
|
||||
*
|
||||
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
|
||||
* whether its page could be reached.
|
||||
@@ -21,26 +22,24 @@ import {
|
||||
import type { LinkPreview } from "../../shared/dump.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
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.
|
||||
// Signed-in only: an open preview would let anyone make the server fetch pages.
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
const responseBody: APIFailure = {
|
||||
success: false,
|
||||
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
|
||||
error: { code: APIErrorCode.BAD_REQUEST, message: "Invalid URL" },
|
||||
};
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
// `reached` is reported separately because a failed fetch still yields a
|
||||
// usable stub (hostname only). Without the flag the create form cannot tell
|
||||
// "this page has no preview" from "this link is dead", and shows the same
|
||||
// bare card for both.
|
||||
const { ok, content } = await tryFetchRichContent(url);
|
||||
const responseBody: APIResponse<LinkPreview> = {
|
||||
success: true,
|
||||
@@ -49,7 +48,10 @@ 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. */
|
||||
/**
|
||||
* Answers with a proxied image. It is sent from this site's origin, so it is
|
||||
* sandboxed in case a browser opens it as a document.
|
||||
*/
|
||||
function sendImage(
|
||||
ctx: { response: { headers: Headers; body?: unknown } },
|
||||
bytes: Uint8Array,
|
||||
@@ -65,8 +67,8 @@ function sendImage(
|
||||
ctx.response.body = bytes;
|
||||
}
|
||||
|
||||
// Proxies an external image through the server so HTTP thumbnail URLs don't
|
||||
// trigger mixed-content blocks when the frontend is served over HTTPS.
|
||||
// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
|
||||
// page won't load, or when its host's hotlink protection refused the browser.
|
||||
previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* The search route.
|
||||
* Searches dumps, users and playlists at once, for the client's `Search` page.
|
||||
* Only the dumps are paged. Private dumps and playlists are found only by
|
||||
* their owner.
|
||||
*
|
||||
* - `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.
|
||||
* matching dumps with their total, and up to 20 matching users and
|
||||
* playlists. A blank `q` finds nothing.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,7 +19,10 @@ import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/search" });
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* The route serving dumps' thumbnails, generated with ffmpeg where needed.
|
||||
* Serves dumps' thumbnails to the cards and pages that show a file dump, which
|
||||
* build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are
|
||||
* generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`.
|
||||
*
|
||||
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
|
||||
* - `GET /api/thumbnails/:dumpId?v=&token=` — 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
|
||||
@@ -11,7 +13,6 @@
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
dumpFileVersion,
|
||||
@@ -23,16 +24,18 @@ import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/thumbnails" });
|
||||
|
||||
/**
|
||||
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on
|
||||
* every uncached thumbnail request cost a process per card.
|
||||
* Holds whether ffmpeg runs, probed once per process: probing 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 {
|
||||
@@ -50,21 +53,23 @@ function ffmpegAvailable(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists the container formats ffmpeg may read a video as. The upload's type is
|
||||
* only what the uploader claimed, and ffmpeg guesses the format from the bytes,
|
||||
* so a "video" could be an HLS or concat playlist pointing it at local files or
|
||||
* internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads
|
||||
* nothing but the file, and only as a video.
|
||||
*/
|
||||
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}). */
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an image as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}.
|
||||
*/
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Maps the image types that get a scaled-down preview to the preview's format.
|
||||
* A GIF is served as it is, to keep its animation.
|
||||
*/
|
||||
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
@@ -75,13 +80,13 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Caps an image preview's width. It covers the widest feed card on a
|
||||
* high-density screen, at a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `args` into `outPath`.
|
||||
* Runs ffmpeg with `args`, writing to `outPath`.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails.
|
||||
*/
|
||||
@@ -121,7 +126,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */
|
||||
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
||||
function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
return runFfmpeg([
|
||||
"-format_whitelist",
|
||||
@@ -141,15 +146,14 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Holds the generations under way, by output path. A feed full of cards asking
|
||||
* for the same new still at once then waits on a single ffmpeg run.
|
||||
*/
|
||||
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).
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*/
|
||||
async function ensureGenerated(
|
||||
outPath: string,
|
||||
@@ -168,20 +172,22 @@ async function ensureGenerated(
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names files on disk: nothing but an id's characters gets through.
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
throw new APIException(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Token via query param (media elements can't send headers) — authorizes the
|
||||
// owner's private dumps and gates the custom-thumbnail branch below too.
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string. This privacy check covers the custom
|
||||
// thumbnail too, since getDumpThumbnailInfo makes none.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
// The URL may carry a media token, which must not leak through a Referer.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
// A private dump's thumbnail must not sit in a shared cache. The app's URLs
|
||||
// carry the file's version (see dumpFileVersion): what's generated from the
|
||||
// file is cached for good under the current one, and revalidated otherwise.
|
||||
// What is generated from the file is cached for good under a URL naming its
|
||||
// current version (dumpFileVersion), and revalidated under any other. A
|
||||
// private dump's thumbnail must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const generatedCache =
|
||||
ctx.request.url.searchParams.get("v") === dumpFileVersion(dump)
|
||||
@@ -190,8 +196,8 @@ router.get("/:dumpId", async (ctx) => {
|
||||
|
||||
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||
if (customThumb) {
|
||||
// Replaceable without the URL changing: always revalidated (a 304 when
|
||||
// unchanged).
|
||||
// Replacing it doesn't change the file's version, so it is always
|
||||
// revalidated.
|
||||
const served = await serveUploadedFile(
|
||||
ctx,
|
||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||
@@ -210,11 +216,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
if (
|
||||
!await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath))
|
||||
) {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Could not generate thumbnail",
|
||||
);
|
||||
throw new APIException(404, "Could not generate thumbnail");
|
||||
}
|
||||
if (
|
||||
await serveFile(ctx, thumbPath, {
|
||||
@@ -248,11 +250,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
) return;
|
||||
}
|
||||
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No thumbnail for this dump",
|
||||
);
|
||||
throw new APIException(404, "No thumbnail for this dump");
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The user and account routes. Public profiles never carry the password hash or
|
||||
* the email.
|
||||
* Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles,
|
||||
* with what each user posted, upvoted, follows and invited. A profile sent to
|
||||
* anyone but its user goes through `toPublicUser`, which drops 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.
|
||||
@@ -42,11 +43,7 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIResponse,
|
||||
type PaginatedData,
|
||||
} from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
type AuthResponse,
|
||||
@@ -69,7 +66,8 @@ import {
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import { toPublicUser, toSelfUser, type User } from "../model/user.ts";
|
||||
|
||||
import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
|
||||
import { createJWT, createMediaToken } from "../lib/jwt.ts";
|
||||
import { hashPassword, verifyPassword } from "../lib/password.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import {
|
||||
type AuthContext,
|
||||
@@ -106,15 +104,21 @@ import {
|
||||
getFollowedUsersByUser,
|
||||
} from "../services/follow-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
// Hashed once, at startup, for the sign-ins of usernames that don't exist.
|
||||
const UNKNOWN_USER_HASH: Promise<string> = hashPassword(crypto.randomUUID());
|
||||
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/users" });
|
||||
|
||||
/** Login attempts per account: a burst of 10, then one every 30s. */
|
||||
/** Allows a burst of 10 login attempts per account, then one every 30s. */
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
/**
|
||||
* Issues `user` a session token, plus the media-only token that goes in file
|
||||
* and thumbnail URLs (see {@link createMediaToken}).
|
||||
* Returns a new session token and media token for `user`, as sign-up, sign-in
|
||||
* and a password change answer them.
|
||||
*/
|
||||
async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
return {
|
||||
@@ -132,14 +136,13 @@ router.post("/register", async (ctx) => {
|
||||
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
validateRegisterUserRequest(body) ?? "Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||
// account can't be created, so one invite can't open two accounts.
|
||||
// Claimed before the async account creation, so that one invite can't open
|
||||
// two accounts, and handed back if the creation fails.
|
||||
const inviterId = claimInvite(body.inviteToken);
|
||||
|
||||
let user;
|
||||
@@ -150,7 +153,7 @@ router.post("/register", async (ctx) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
// Not awaited: a slow or failing SMTP server mustn't hold up the sign-up.
|
||||
if (isEmailEnabled()) {
|
||||
const emailMarkdown = WELCOME_EMAIL_BODY
|
||||
.replaceAll("{{username}}", user.username)
|
||||
@@ -176,18 +179,13 @@ router.post("/login", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
|
||||
// Per account, so guessing one user's password is slow however many
|
||||
// connections it comes from.
|
||||
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
429,
|
||||
"Too many login attempts — try again in a minute",
|
||||
);
|
||||
@@ -197,21 +195,16 @@ router.post("/login", async (ctx) => {
|
||||
try {
|
||||
user = getUserByUsername(body.username);
|
||||
} catch {
|
||||
// Same answer as a wrong password: don't confirm which usernames exist.
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
user = undefined;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
// An unknown username gets the same answer as a wrong password, after the
|
||||
// same work, so neither the message nor the timing tells which exist.
|
||||
const valid = await verifyPassword(
|
||||
body.password,
|
||||
user?.passwordHash ?? await UNKNOWN_USER_HASH,
|
||||
);
|
||||
if (!user || !valid) {
|
||||
throw new APIException(401, "Invalid username or password");
|
||||
}
|
||||
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
@@ -222,29 +215,11 @@ router.post("/login", async (ctx) => {
|
||||
});
|
||||
|
||||
router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
try {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authenticated",
|
||||
);
|
||||
}
|
||||
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Failed to fetch user profile",
|
||||
);
|
||||
}
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
@@ -255,6 +230,8 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Answers the same whatever happens, so it can't tell which addresses have an
|
||||
// account.
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
@@ -273,11 +250,7 @@ router.post("/reset-password", async (ctx) => {
|
||||
const token = fieldOf(body, "token");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
if (typeof token !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
await resetPassword(token, newPassword);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
@@ -290,11 +263,7 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
|
||||
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -302,7 +271,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||
) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`,
|
||||
);
|
||||
@@ -311,11 +279,7 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const user = getUserById(ctx.state.user.userId);
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Current password is incorrect",
|
||||
);
|
||||
throw new APIException(403, "Current password is incorrect");
|
||||
}
|
||||
|
||||
// Changing the password revokes every token issued before it, this
|
||||
@@ -333,16 +297,11 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
// Only the profile fields. The role is assigned by an admin, the password
|
||||
// changes through /me/change-password (which asks for the current one), and
|
||||
// the username is fixed — a token alone must not be able to change any of
|
||||
// them.
|
||||
// Only the profile fields: a stolen token alone must not change the role,
|
||||
// the password (which needs the current one) or the username (which is
|
||||
// fixed).
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
@@ -364,20 +323,11 @@ router.patch(
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const role = fieldOf(body, "role");
|
||||
if (!isRole(role)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid role",
|
||||
);
|
||||
throw new APIException(400, "Invalid role");
|
||||
}
|
||||
// Disallow changing your own role: prevents an admin from accidentally
|
||||
// locking themselves out, and self-promotion is meaningless anyway.
|
||||
// So that an admin can't demote themselves by mistake and leave no admin.
|
||||
if (ctx.params.userId === ctx.state.user.userId) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Cannot change your own role",
|
||||
);
|
||||
throw new APIException(400, "Cannot change your own role");
|
||||
}
|
||||
const updated = await updateUser(ctx.params.userId, { role });
|
||||
const publicUser = toPublicUser(updated);
|
||||
|
||||
125
api/routes/ws.ts
125
api/routes/ws.ts
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* The WebSocket route, and the handling of what clients send over it.
|
||||
* Opens the WebSocket that `WSProvider` keeps for each tab, and handles what
|
||||
* the client sends over it. Voting, comment likes and every chat write go
|
||||
* through here, not through HTTP. The messages are typed in `shared/ws.ts`,
|
||||
* and `services/ws-service.ts` sends the broadcasts.
|
||||
*
|
||||
* - `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}.
|
||||
* - `GET /ws?token=`, public. A valid session `token` signs the socket in, and
|
||||
* an invalid one leaves it anonymous. Answers 403 to an origin not in
|
||||
* {@link ALLOWED_ORIGINS}, and 426 to a request that isn't an upgrade.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -62,10 +63,17 @@ import type {
|
||||
} from "../../shared/ws.ts";
|
||||
import type { User } from "../model/user.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
/** Checks whether a socket may be opened from `origin`. */
|
||||
/**
|
||||
* Checks whether a socket may be opened from `origin`. Browsers don't apply
|
||||
* CORS to WebSockets, so this is what stops another site from opening one with
|
||||
* its visitors' tokens.
|
||||
*/
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
return ALLOWED_ORIGINS.includes(origin);
|
||||
}
|
||||
@@ -82,9 +90,8 @@ router.get("/ws", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// authenticate() refuses a token whose user is gone or which predates a
|
||||
// password change, and reads the live role from the DB. Such a socket is
|
||||
// simply anonymous; the client's next API call gets the 401.
|
||||
// A revoked token (deleted user, changed password) opens an anonymous
|
||||
// socket. The client signs out on its next API call, which gets a 401.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const authPayload = token ? await authenticate(token) : null;
|
||||
let user: User | undefined;
|
||||
@@ -104,9 +111,11 @@ router.get("/ws", async (ctx) => {
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
|
||||
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||
// addEventListener, since `onopen =` proved unreliable under Deno.serve.
|
||||
socket.addEventListener("open", () => {
|
||||
register(client);
|
||||
// The new socket is registered, so it gets this presence_update too,
|
||||
// before its welcome.
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
@@ -141,12 +150,16 @@ router.get("/ws", async (ctx) => {
|
||||
socket.addEventListener("close", () => {
|
||||
unregister(client);
|
||||
broadcastPresence();
|
||||
// If they were mid-compose, drop them from the typing set right away.
|
||||
// The sweep only sees registered clients, so it would never clear this one.
|
||||
if (client.typingAt) broadcastTyping();
|
||||
});
|
||||
});
|
||||
|
||||
/** Parses one message from a client and dispatches it by `type`. */
|
||||
/**
|
||||
* Parses one message from a client and dispatches it by `type`. A message that
|
||||
* isn't JSON, or whose `type` is unknown, is ignored. Its fields are still
|
||||
* untrusted, so each handler checks their types.
|
||||
*/
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
@@ -155,8 +168,6 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Anything but an object with a string `type` (`null`, a number, an array)
|
||||
// is ignored rather than read from.
|
||||
if (
|
||||
typeof parsed !== "object" || parsed === null ||
|
||||
typeof (parsed as { type?: unknown }).type !== "string"
|
||||
@@ -187,7 +198,7 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
// Typing is cosmetic: past the limit it's dropped, with no error.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
@@ -210,14 +221,15 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
|
||||
/** Sends `data` unless the socket is closing, where `send` would throw. */
|
||||
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.
|
||||
* Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and
|
||||
* broadcasts `votes_update`. A failure goes back as an `error` naming the dump
|
||||
* and the action, so the client can undo its optimistic vote at once.
|
||||
*/
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
@@ -244,8 +256,7 @@ function handleVote(
|
||||
voteCount,
|
||||
});
|
||||
|
||||
// Only a private dump's owner can vote on it, so they're its whole
|
||||
// audience.
|
||||
// Only a private dump's owner can see it, and so vote on it.
|
||||
broadcastVoteUpdate(
|
||||
dumpId,
|
||||
voteCount,
|
||||
@@ -260,8 +271,8 @@ function handleVote(
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's like on a comment, acknowledges it, and
|
||||
* broadcasts the new count.
|
||||
* Handles `comment_like_cast` and `comment_like_remove`, as
|
||||
* {@link handleVote} does votes.
|
||||
*/
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
@@ -302,20 +313,23 @@ function handleCommentLike(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Limits each user's chat writes, sends, edits and deletes together, to a burst
|
||||
* of 8, then one every 1.5 s. The buckets are per user, so more tabs don't buy
|
||||
* more.
|
||||
*/
|
||||
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 per user. They are cheap for the client to send often; past
|
||||
* this they're dropped, and the next one catches up.
|
||||
* Limits each user's `chat_read`. One over the limit is dropped, and the next
|
||||
* one catches up, since a marker only moves forward.
|
||||
*/
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
|
||||
/**
|
||||
* Sends a chat-scoped `error`. With the `nonce` of a `chat_send`, the chatbox
|
||||
* marks that pending message failed. Without one, it shows `message` as a
|
||||
* notice.
|
||||
*/
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
@@ -325,8 +339,8 @@ function sendChatError(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the id of the user behind a chat write, or undefined after sending
|
||||
* the error when they are signed out or over {@link CHAT_WRITE_LIMIT}.
|
||||
*/
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
@@ -344,7 +358,9 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */
|
||||
/**
|
||||
* 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
|
||||
@@ -352,8 +368,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
|
||||
* already reading the chat.
|
||||
* Handles `chat_send`. It stores the message, broadcasts it, and notifies the
|
||||
* @mentioned users who aren't reading the chat.
|
||||
*/
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
@@ -375,14 +391,10 @@ function handleChatSend(
|
||||
body,
|
||||
optionalString(replyToId, 64),
|
||||
);
|
||||
// Sending a message ends composition — clear the typing flag before the
|
||||
// message broadcast so the indicator doesn't linger next to it.
|
||||
// Before the broadcast, so the typing indicator doesn't linger beside the
|
||||
// message.
|
||||
setClientTyping(client, false);
|
||||
// Broadcast to everyone including the sender, whose pending copy the nonce
|
||||
// retires.
|
||||
broadcastChatMessage(message, nonce);
|
||||
// Skip mention notifications for users who already have the chat open —
|
||||
// they've seen the message live.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.body,
|
||||
@@ -402,10 +414,9 @@ function handleChatSend(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether the client's user may edit and delete others' chat messages.
|
||||
* The role is read from the database, not from the socket's connect, so that a
|
||||
* demoted moderator loses the right at once.
|
||||
*/
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
@@ -417,7 +428,10 @@ function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
|
||||
/**
|
||||
* Handles `chat_edit`. It stores the new body, broadcasts it, and notifies, in
|
||||
* the author's name, the users the edit newly mentions.
|
||||
*/
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -434,10 +448,11 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
canModerateChat(client),
|
||||
);
|
||||
broadcastChatMessageUpdated(message);
|
||||
// A newly added @mention in an edit should still notify (deduped per
|
||||
// message+user), skipping anyone already reading the chat.
|
||||
// Mentions already notified for this message are deduplicated, so only
|
||||
// the new ones notify. A moderator's edit keeps the author's words, so the
|
||||
// author mentions.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.userId,
|
||||
message.body,
|
||||
"chat",
|
||||
message.id,
|
||||
@@ -453,7 +468,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a chat message and broadcasts it. */
|
||||
/** Handles `chat_delete`. It deletes the message and broadcasts its id. */
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -474,8 +489,9 @@ 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.
|
||||
* Handles `chat_read`. It moves the user's read marker and sends
|
||||
* `chat_read_update` to all their sockets, so every tab and device updates its
|
||||
* badge together.
|
||||
*/
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
@@ -488,7 +504,8 @@ function handleChatRead(client: WsClient, at: unknown): void {
|
||||
unread: state.unread,
|
||||
});
|
||||
} catch {
|
||||
// A malformed marker changes nothing; there's nothing to report.
|
||||
// A malformed marker changes nothing, and the client doesn't wait for an
|
||||
// answer.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* The YouTube route, for the player's tracklist.
|
||||
* Serves the titles and lengths of a YouTube playlist's entries, which the
|
||||
* global player lists beside the embed. On any failure it shows the embed
|
||||
* without a tracklist.
|
||||
*
|
||||
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id
|
||||
* to its tracklist (titles and lengths).
|
||||
* - `GET /api/youtube/playlist?list=` — public. Answers the playlist's
|
||||
* tracklist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,11 +14,13 @@ import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||
import type { YouTubePlaylist } from "../../shared/player.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
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".
|
||||
// Public, like /api/bandcamp/tracks: it only reads a public page.
|
||||
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||
const responseBody: APIResponse<YouTubePlaylist> = {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 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
|
||||
* Draws the favicons and writes the manifest from {@link SITE_EMOJI} and
|
||||
* {@link OG_SITE_NAME}, so changing them needs a restart and no rebuild.
|
||||
* `deno task icons <dir>` runs it before the server starts, into the directory
|
||||
* the frontend is served from, relative to the working directory. That is
|
||||
* dist/ for `start` and `serve`, and public/, which Vite serves, for `dev`.
|
||||
* It does nothing when the directory doesn't exist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,59 +1,131 @@
|
||||
/**
|
||||
* 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.
|
||||
* Records the images pasted into Markdown bodies, and binds each one to the
|
||||
* resource whose body links it. `routes/attachments.ts` stores and serves the
|
||||
* files; the services that save a body call {@link linkAttachments}.
|
||||
*
|
||||
* @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. */
|
||||
/**
|
||||
* Records attachment `id` once `POST /api/attachments` has written its file.
|
||||
* Until a saved body links it, the startup purge deletes it after
|
||||
* `ORPHANED_ATTACHMENTS_RETENTION_HOURS`.
|
||||
*/
|
||||
export function createAttachment(id: string, mime: string): void {
|
||||
db.prepare(
|
||||
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
|
||||
).run(id, mime, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** The kinds of resource whose saved text can link an attachment. */
|
||||
export type AttachmentResourceType =
|
||||
| "dump"
|
||||
| "comment"
|
||||
| "playlist"
|
||||
| "chat"
|
||||
| "user";
|
||||
|
||||
/**
|
||||
* Returns attachment `attachmentId`'s MIME type.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such attachment.
|
||||
* Returned to `GET /api/attachments/:attachmentId` by
|
||||
* {@link getAttachmentAccess}. `ownerId` is set when only that user may see
|
||||
* the image, because the resource linking it is private.
|
||||
*/
|
||||
export function getAttachment(attachmentId: string): { mime: string } {
|
||||
const row = db.prepare(
|
||||
"SELECT mime FROM attachments WHERE id = ?",
|
||||
).get(attachmentId) as { mime: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Attachment not found",
|
||||
);
|
||||
}
|
||||
|
||||
return { mime: row.mime };
|
||||
export interface AttachmentAccess {
|
||||
mime: string;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns attachment `attachmentId`'s type and who may see it. An attachment
|
||||
* nothing links yet is public, so the editor can show it before the text is
|
||||
* saved; its id is unguessable. One linked by a private dump or playlist, or
|
||||
* by a comment on a private dump, is its owner's.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such attachment, or when the
|
||||
* resource linking it has been deleted.
|
||||
*/
|
||||
export function getAttachmentAccess(attachmentId: string): AttachmentAccess {
|
||||
const row = db.prepare(
|
||||
"SELECT mime, resource_type, resource_id FROM attachments WHERE id = ?",
|
||||
).get(attachmentId) as
|
||||
| { mime: string; resource_type: string | null; resource_id: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new APIException(404, "Attachment not found");
|
||||
if (!row.resource_id) return { mime: row.mime };
|
||||
|
||||
const owner = resourceOwner(row.resource_type, row.resource_id);
|
||||
if (owner === undefined) {
|
||||
throw new APIException(404, "Attachment not found");
|
||||
}
|
||||
return owner ? { mime: row.mime, ownerId: owner } : { mime: row.mime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the owner of a private resource, null for a public one, and
|
||||
* undefined for one that no longer exists.
|
||||
*/
|
||||
function resourceOwner(
|
||||
type: string | null,
|
||||
id: string,
|
||||
): string | null | undefined {
|
||||
switch (type) {
|
||||
case "dump": {
|
||||
const dump = db.prepare(
|
||||
"SELECT user_id, is_private FROM dumps WHERE id = ?",
|
||||
).get(id) as { user_id: string; is_private: number } | undefined;
|
||||
return dump && (dump.is_private ? dump.user_id : null);
|
||||
}
|
||||
case "comment": {
|
||||
const comment = db.prepare(
|
||||
`SELECT d.user_id, d.is_private FROM comments c
|
||||
JOIN dumps d ON d.id = c.dump_id WHERE c.id = ? AND c.deleted = 0`,
|
||||
).get(id) as { user_id: string; is_private: number } | undefined;
|
||||
return comment && (comment.is_private ? comment.user_id : null);
|
||||
}
|
||||
case "playlist": {
|
||||
const playlist = db.prepare(
|
||||
"SELECT user_id, is_public FROM playlists WHERE id = ?",
|
||||
).get(id) as { user_id: string; is_public: number } | undefined;
|
||||
return playlist && (playlist.is_public ? null : playlist.user_id);
|
||||
}
|
||||
case "chat":
|
||||
return db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(id)
|
||||
? null
|
||||
: undefined;
|
||||
case "user":
|
||||
return db.prepare("SELECT 1 FROM users WHERE id = ?").get(id)
|
||||
? null
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Binds the attachments whose URLs appear in `text` to the resource that saved
|
||||
* it, so the startup purge keeps them as long as that resource exists, and
|
||||
* they are seen by whoever may see it. It is called whenever a dump, comment,
|
||||
* chat message, playlist or profile description is saved. An attachment
|
||||
* already bound stays with its resource, so pasting another's URL claims
|
||||
* nothing, and removing the link from a body never unbinds it.
|
||||
*/
|
||||
export function linkAttachments(text: string, resourceId: string): void {
|
||||
export function linkAttachments(
|
||||
text: string,
|
||||
resourceType: AttachmentResourceType,
|
||||
resourceId: string,
|
||||
): void {
|
||||
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
||||
if (ids.length === 0) return;
|
||||
const update = db.prepare(
|
||||
"UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
||||
"UPDATE attachments SET resource_type = ?, resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
||||
);
|
||||
transaction(() => {
|
||||
for (const id of ids) update.run(resourceId, id);
|
||||
for (const id of ids) update.run(resourceType, resourceId, id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Backlinks between dumps: which dumps a dump's description and comments link
|
||||
* to, and the dumps related to one through them.
|
||||
* Records which dumps a dump's note and comments link to, and finds the dumps
|
||||
* related to one through those links. The dump and comment services keep the
|
||||
* links up to date, and the dump page lists the related dumps.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,32 +14,23 @@ import {
|
||||
isDumpRow,
|
||||
} from "../model/dump.ts";
|
||||
import { UUID_RE } from "../lib/slugify.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Matches an http(s) URL in Markdown. It stops at the delimiters of
|
||||
* `[text](url)` and `<url>`, which would otherwise end up in the URL.
|
||||
*/
|
||||
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).
|
||||
* Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what
|
||||
* follows it, so an `/edit` or `#comment-x` link counts too. The host isn't
|
||||
* checked, since the site's public URL differs between setups.
|
||||
*/
|
||||
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();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */
|
||||
/** Returns the id of the dump with id or slug `idOrSlug`, or null. */
|
||||
function resolveDumpPathId(idOrSlug: string): string | null {
|
||||
const row = UUID_RE.test(idOrSlug)
|
||||
? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||
@@ -47,11 +39,11 @@ function resolveDumpPathId(idOrSlug: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
* which are purely additive.
|
||||
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
|
||||
* of its comments (`comment`), with the links its current `text` holds. A link
|
||||
* counts when it is a dump's page on this site, or when it is the URL a dump
|
||||
* was posted with, compared by `canonicalizeUrl` as reposts are. The dump and comment services call it on every save, and with
|
||||
* an empty `text` when a comment is deleted.
|
||||
*/
|
||||
export function relinkBacklinks(
|
||||
sourceType: "dump_comment" | "comment",
|
||||
@@ -63,11 +55,9 @@ export function relinkBacklinks(
|
||||
const externalUrls = new Set<string>();
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
const canonical = canonicalize(match[0]);
|
||||
if (!canonical) continue;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(canonical);
|
||||
parsed = new URL(match[0]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -76,19 +66,20 @@ export function relinkBacklinks(
|
||||
const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1]));
|
||||
if (resolved) targetIds.add(resolved);
|
||||
} else {
|
||||
externalUrls.add(canonical);
|
||||
const canonical = canonicalizeUrl(match[0]);
|
||||
if (canonical) externalUrls.add(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
if (externalUrls.size > 0) {
|
||||
const placeholders = [...externalUrls].map(() => "?").join(", ");
|
||||
const rows = db.prepare(
|
||||
`SELECT id FROM dumps WHERE url IN (${placeholders});`,
|
||||
`SELECT id FROM dumps WHERE url_canonical IN (${placeholders});`,
|
||||
).all(...externalUrls) as { id: string }[];
|
||||
for (const row of rows) targetIds.add(row.id);
|
||||
}
|
||||
|
||||
targetIds.delete(fromDumpId); // no self-links
|
||||
targetIds.delete(fromDumpId);
|
||||
|
||||
transaction(() => {
|
||||
db.prepare(
|
||||
@@ -107,7 +98,8 @@ export function relinkBacklinks(
|
||||
|
||||
/**
|
||||
* Returns the dumps linked to or from `dumpId`, newest first and at most 20,
|
||||
* without the private ones the requesting user can't see.
|
||||
* without the private ones the requesting user can't see. It doesn't check
|
||||
* `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first.
|
||||
*/
|
||||
export function getRelatedDumps(
|
||||
dumpId: string,
|
||||
@@ -122,5 +114,5 @@ export function getRelatedDumps(
|
||||
ORDER BY created_at DESC LIMIT 20;`,
|
||||
).all(dumpId, dumpId, requestingUserId ?? "");
|
||||
|
||||
return rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
return parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Categories: managing them, and which dumps belong to which.
|
||||
* Stores the categories and which dumps belong to them. The category routes
|
||||
* manage and list them, the dump service files each dump it writes under its
|
||||
* categories, and every service that lists dumps fills in their `categoryIds`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
Category,
|
||||
@@ -22,41 +23,37 @@ import {
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */
|
||||
/**
|
||||
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
|
||||
* with a message for each case.
|
||||
*/
|
||||
function validateSlug(slug: string): void {
|
||||
if (!isValidSlugFormat(slug)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Slug must be lowercase letters, numbers and hyphens",
|
||||
);
|
||||
}
|
||||
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
|
||||
);
|
||||
}
|
||||
if (isReservedSlug(slug)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Slug "${slug}" is reserved`,
|
||||
);
|
||||
throw new APIException(400, `Slug "${slug}" is reserved`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required");
|
||||
throw new APIException(400, "Name is required");
|
||||
}
|
||||
if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`,
|
||||
);
|
||||
@@ -65,55 +62,51 @@ function requireName(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending
|
||||
* field, and anything else as is.
|
||||
* Rethrows a UNIQUE violation from SQLite as a 409 naming the slug or name
|
||||
* that is taken, and any other error as is. SQLite's message names the
|
||||
* column, so this matches on it.
|
||||
*/
|
||||
function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("categories.name")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
`Name "${name}" is already in use`,
|
||||
);
|
||||
throw new APIException(409, `Name "${name}" is already in use`);
|
||||
}
|
||||
if (msg.includes("UNIQUE") || msg.includes("unique")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
`Slug "${slug}" is already in use`,
|
||||
);
|
||||
throw new APIException(409, `Slug "${slug}" is already in use`);
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns category `id`.
|
||||
* Returns category `id`, for {@link updateCategory}.
|
||||
*
|
||||
* @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)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
throw new APIException(404, "Category not found");
|
||||
}
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`.
|
||||
* Returns the category at `slug`, for `GET /api/categories/: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)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
throw new APIException(404, "Category not found");
|
||||
}
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/** Returns the id of the category at `slug`, or undefined when there is none. */
|
||||
/**
|
||||
* Returns the id of the category at `slug`, or undefined when there is none.
|
||||
* The follows routes use it for their `?category=` feed filter.
|
||||
*/
|
||||
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"
|
||||
@@ -121,16 +114,19 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns every category, by position then slug. */
|
||||
/**
|
||||
* Returns every category, by position then slug, for `GET /api/categories`.
|
||||
*/
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
).all();
|
||||
return rows.filter(isCategoryRow).map(categoryRowToApi);
|
||||
return parseRows(rows, isCategoryRow, categoryRowToApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a category.
|
||||
* Creates a category, for `POST /api/categories`. The name is stored trimmed,
|
||||
* and the position defaults to 0.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
@@ -166,7 +162,8 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `req` to category `id`.
|
||||
* Applies `req` to category `id`, for `PUT /api/categories/:id`, and returns
|
||||
* the category as it now is. Fields `req` leaves out keep their value.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category, 400 when the new
|
||||
* slug or name is invalid, 409 when either is taken.
|
||||
@@ -211,21 +208,22 @@ export function updateCategory(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`; its dumps stay, outside it.
|
||||
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
|
||||
* lose it from their `categoryIds`.
|
||||
*
|
||||
* @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) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
throw new APIException(404, "Category not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks that every id in `categoryIds` names a category. The dump service
|
||||
* runs it before it writes a dump row or stores an uploaded file, so a bad id
|
||||
* fails the request before anything is written.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
@@ -238,18 +236,15 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
`SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`,
|
||||
).get(...unique) as { count: number } | undefined;
|
||||
if ((found?.count ?? 0) !== unique.length) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"One or more categories do not exist",
|
||||
);
|
||||
throw new APIException(400, "One or more categories do not exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the set of categories a dump belongs to. An empty list clears all
|
||||
* category assignments.
|
||||
* Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
|
||||
* dropped. An empty list takes the dump out of every category. Called inside
|
||||
* the dump service's transaction, it joins that one.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
@@ -271,7 +266,10 @@ export function setDumpCategories(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fills `categoryIds` on each dump with a single batched lookup. */
|
||||
/**
|
||||
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
|
||||
* doesn't carry them, so every service that returns dumps calls this first.
|
||||
*/
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
@@ -292,8 +290,9 @@ 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.
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, for
|
||||
* `GET /api/categories/:slug/dumps`. Private dumps are left out, except
|
||||
* `requestingUserId`'s own.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
@@ -319,7 +318,7 @@ export function listDumpsByCategory(
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(category.id, ...privacyParams, limit + 1, offset);
|
||||
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* The site-wide chat: messages, their history, and each user's read marker.
|
||||
* Stores the site-wide chat's messages and each user's read marker.
|
||||
*
|
||||
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
|
||||
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
|
||||
* time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
|
||||
@@ -12,49 +15,46 @@ 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How much of the replied-to message a reply carries. The client shows one line
|
||||
* of it, once stripped of its Markdown, so the cut leaves room for a long link
|
||||
* or image URL before the text.
|
||||
*/
|
||||
const REPLY_SNIPPET_LEN = 300;
|
||||
|
||||
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
|
||||
/** The columns of a `ChatMessageRow`, to select 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`;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Joins a message to its author, and to the message it replies to and that
|
||||
* message's author. Those two are left joins: a reply outlives its target.
|
||||
*/
|
||||
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. */
|
||||
/** Returns message `id` as the broadcast sends it, or throws a 404. */
|
||||
function fetchMessage(id: string): ChatMessage {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
|
||||
).get(id);
|
||||
if (!row || !isChatMessageRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
||||
throw new APIException(404, "Message not found");
|
||||
}
|
||||
return chatMessageRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a message from `userId`, replying to `replyToId` when that message
|
||||
* exists, and binds its attachments.
|
||||
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
|
||||
* it links to. A `replyToId` naming no message is dropped, and the message is
|
||||
* posted as no reply. The caller broadcasts the result.
|
||||
*
|
||||
* @throws {APIException} 400 when the body is blank or longer than
|
||||
* `VALIDATION.CHAT_MESSAGE_MAX`.
|
||||
@@ -66,21 +66,14 @@ export function createChatMessage(
|
||||
): ChatMessage {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Message cannot be empty",
|
||||
);
|
||||
throw new APIException(400, "Message cannot be empty");
|
||||
}
|
||||
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
|
||||
);
|
||||
}
|
||||
// Only store the reply target if it actually exists — drop dangling ids so a
|
||||
// message never references a phantom parent.
|
||||
let replyTo: string | null = null;
|
||||
if (replyToId) {
|
||||
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
|
||||
@@ -93,28 +86,28 @@ export function createChatMessage(
|
||||
db.prepare(
|
||||
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(id, userId, trimmed, createdAt, replyTo);
|
||||
// Claim any pasted/dropped image attachments so the startup cleanup doesn't
|
||||
// purge them as orphans (mirrors dumps/comments/playlists).
|
||||
linkAttachments(trimmed, id);
|
||||
// Unclaimed attachments are deleted as orphans at startup.
|
||||
linkAttachments(trimmed, "chat", id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
/** Returns the author of message `id`, or throws a 404. */
|
||||
/** Returns the id of message `id`'s author, or throws a 404. */
|
||||
function getMessageOwner(id: string): string {
|
||||
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
|
||||
id,
|
||||
) as { user_id: string } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
||||
throw new APIException(404, "Message not found");
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a message's body, as its author or a moderator (`canModerate`), and
|
||||
* binds any new attachment.
|
||||
* Replaces a message's body on a `chat_edit`, and claims any image the new body
|
||||
* links to. `canModerate` lets a moderator edit anyone's message. The caller
|
||||
* broadcasts the result.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such message, 401 when the user
|
||||
* @throws {APIException} 404 when there is no such message, 403 when the user
|
||||
* is neither its author nor a moderator, 400 when the body is blank or too
|
||||
* long.
|
||||
*/
|
||||
@@ -126,38 +119,29 @@ export function updateChatMessage(
|
||||
): ChatMessage {
|
||||
const ownerId = getMessageOwner(id);
|
||||
if (ownerId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to edit this message",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to edit this message");
|
||||
}
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Message cannot be empty",
|
||||
);
|
||||
throw new APIException(400, "Message cannot be empty");
|
||||
}
|
||||
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
|
||||
);
|
||||
}
|
||||
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
|
||||
.run(trimmed, new Date().toISOString(), id);
|
||||
// An edit may introduce a freshly pasted image; claim its attachment too.
|
||||
linkAttachments(trimmed, id);
|
||||
linkAttachments(trimmed, "chat", id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a message, as its author or a moderator (`canModerate`).
|
||||
* Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
|
||||
* anyone's message. Replies to it keep their `replyToId`, without its preview.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such message, 401 when the user
|
||||
* @throws {APIException} 404 when there is no such message, 403 when the user
|
||||
* is neither its author nor a moderator.
|
||||
*/
|
||||
export function deleteChatMessage(
|
||||
@@ -165,53 +149,47 @@ export function deleteChatMessage(
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): void {
|
||||
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
|
||||
const ownerId = getMessageOwner(id);
|
||||
if (ownerId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete this message",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to delete this message");
|
||||
}
|
||||
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Passed by `routes/chat.ts` to page through the history, from the message the
|
||||
* client already has at one end. Messages are ordered by `createdAt`, then by
|
||||
* id, so a page boundary between two messages sent in the same millisecond
|
||||
* skips neither.
|
||||
*/
|
||||
export interface ChatCursor {
|
||||
/** The `created_at` of the message the cursor sits at. */
|
||||
/** An ISO timestamp, compared as a string with `created_at`. */
|
||||
createdAt: string;
|
||||
/**
|
||||
* The id of that message. Omitted: the cursor sits before (for `after`) or
|
||||
* after (for `before`) every message sharing that timestamp.
|
||||
* Without it, the cursor takes in every message at `createdAt`: they are
|
||||
* all older for {@link getRecentChatMessages} and all newer for
|
||||
* {@link getChatMessagesAfter}.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Converts query rows to messages. */
|
||||
/** Converts query rows to messages, or throws a 500 if one doesn't parse. */
|
||||
function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed chat message data",
|
||||
);
|
||||
throw new APIException(500, "Malformed chat message data");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` most recent messages in ascending (display) order.
|
||||
* When `before` is given, returns the page of older messages immediately
|
||||
* preceding it — used for "load older" history.
|
||||
* Returns the latest `limit` messages, or with `before` the `limit` messages
|
||||
* just older than it, oldest first either way. `limit` is clamped to 1–100.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a message.
|
||||
*/
|
||||
export function getRecentChatMessages(
|
||||
limit = DEFAULT_LIMIT,
|
||||
@@ -226,7 +204,7 @@ export function getRecentChatMessages(
|
||||
).all(
|
||||
before.createdAt,
|
||||
before.createdAt,
|
||||
// No id: nothing at that timestamp sorts below the empty string.
|
||||
// Nothing sorts below "", so no message at that timestamp is taken.
|
||||
before.id ?? "",
|
||||
capped,
|
||||
)
|
||||
@@ -234,14 +212,15 @@ export function getRecentChatMessages(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return toMessages(rows).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` messages following `after`, in ascending order — used
|
||||
* to resync the chat after a reconnect. Without an id the cursor is inclusive
|
||||
* of its timestamp.
|
||||
* Returns the first `limit` messages after `after`, oldest first. The chatbox
|
||||
* uses it to fetch what it missed while the socket was down. `limit` is
|
||||
* clamped to 1–100.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a message.
|
||||
*/
|
||||
export function getChatMessagesAfter(
|
||||
after: ChatCursor,
|
||||
@@ -255,17 +234,21 @@ export function getChatMessagesAfter(
|
||||
return toMessages(rows);
|
||||
}
|
||||
|
||||
/** How far a user has read the chat. */
|
||||
/**
|
||||
* Returned to the WebSocket route by {@link getChatReadState} and
|
||||
* {@link markChatRead}. The route sends it in `welcome` and in
|
||||
* `chat_read_update`, and the client shows `unread` on the chat's badge.
|
||||
*/
|
||||
export interface ChatReadState {
|
||||
/** The `created_at` of the newest message the user has seen (ISO). */
|
||||
/** The `created_at` of the newest message the user has read. */
|
||||
readAt: string;
|
||||
/** Messages from other people since then. */
|
||||
/** Counts only the messages of other users after `readAt`. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how far `userId` has read the chat; a user who never opened it has
|
||||
* read up to their registration.
|
||||
* Returns how far `userId` has read the chat. A user who never opened it has
|
||||
* read up to their registration, so the history from before it isn't unread.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
@@ -278,15 +261,16 @@ export function getChatReadState(userId: string): ChatReadState {
|
||||
FROM users u WHERE u.id = ?;`,
|
||||
).get(userId) as { read_at: string; unread: number } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||
throw new APIException(404, "User not found");
|
||||
}
|
||||
return { readAt: row.read_at, unread: row.unread };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Moves `userId`'s read marker to `at`, a message's `created_at`, on a
|
||||
* `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
|
||||
* read. It never passes the newest message, so a timestamp from the future
|
||||
* can't mark messages read before they are sent.
|
||||
*
|
||||
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
|
||||
* such user.
|
||||
@@ -294,17 +278,12 @@ export function getChatReadState(userId: string): ChatReadState {
|
||||
export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const parsed = new Date(at);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid read marker",
|
||||
);
|
||||
throw new APIException(400, "Invalid read marker");
|
||||
}
|
||||
const newest = db.prepare(
|
||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||
).get() as { newest: string | null };
|
||||
// No messages yet: there's nothing to have read, and no newest message to
|
||||
// cap a bogus far-future marker at — which could never be moved back.
|
||||
// With no message to cap it at, a marker from the future would stay stuck.
|
||||
if (!newest.newest) return getChatReadState(userId);
|
||||
const iso = parsed.toISOString();
|
||||
const target = iso > newest.newest ? newest.newest : iso;
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
/**
|
||||
* Likes on comments.
|
||||
* Stores likes on comments and keeps each comment's `like_count` in step with
|
||||
* them. Likes have no HTTP endpoint to cast or remove. The WebSocket route
|
||||
* (`routes/ws.ts`) calls this module on `comment_like_cast` and
|
||||
* `comment_like_remove` messages, then broadcasts the new count. The comment
|
||||
* routes use it only to list a comment's likers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
|
||||
import { db } from "../db/connection.ts";
|
||||
import { db, transaction } 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. */
|
||||
/**
|
||||
* Returned to the WebSocket route by {@link castCommentLike} and
|
||||
* {@link removeCommentLike}. The route acks the liker with `likeCount`, then
|
||||
* broadcasts it to every client, or to the liker's alone when the comment's
|
||||
* dump `isPrivate`.
|
||||
*/
|
||||
export interface LikeResult {
|
||||
/** The comment's like count now. */
|
||||
likeCount: number;
|
||||
/** Whether the comment's dump is private: its likes go to the dump's owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the comment's dump is private, or throws a 404 when the user
|
||||
* Returns whether the comment's dump is private, or throws a 404 when `userId`
|
||||
* can't see it.
|
||||
*/
|
||||
function commentVisibility(commentId: string, userId: string): boolean {
|
||||
@@ -29,13 +35,14 @@ function commentVisibility(commentId: string, userId: string): boolean {
|
||||
WHERE c.id = ?;`,
|
||||
).get(commentId) as { is_private: number; user_id: string } | undefined;
|
||||
if (!row || (row.is_private && row.user_id !== userId)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
throw new APIException(404, "Comment not found");
|
||||
}
|
||||
return Boolean(row.is_private);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `userId`'s like on a comment and notifies its author.
|
||||
* Records `userId`'s like on a comment and notifies the comment's author. The
|
||||
* notification is best-effort: if it fails, the like still counts.
|
||||
*
|
||||
* @throws {APIException} 404 when the comment doesn't exist or sits on someone
|
||||
* else's private dump, 409 when the user already likes it.
|
||||
@@ -47,32 +54,25 @@ export function castCommentLike(
|
||||
const isPrivate = commentVisibility(commentId, userId);
|
||||
let likeCount: number;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
db.prepare(
|
||||
`INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(commentId, userId, new Date().toISOString());
|
||||
db.prepare(
|
||||
`UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`,
|
||||
).run(commentId);
|
||||
const row = db.prepare(
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
likeCount = row?.like_count ?? 0;
|
||||
likeCount = transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(commentId, userId, new Date().toISOString());
|
||||
db.prepare(
|
||||
`UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`,
|
||||
).run(commentId);
|
||||
const row = db.prepare(
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number };
|
||||
return row.like_count;
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
db.exec("ROLLBACK;");
|
||||
} catch { /* ignore if no active transaction */ }
|
||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Already liked",
|
||||
);
|
||||
throw new APIException(409, "Already liked");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Notification is best-effort — must not prevent like_ack from being sent
|
||||
// The like is committed: a failed notification must not report it as failed.
|
||||
try {
|
||||
notifyCommentOwnerLike(userId, commentId);
|
||||
} catch { /* ignore */ }
|
||||
@@ -80,8 +80,9 @@ export function castCommentLike(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Takes back `userId`'s like on a comment. It doesn't check that the user can
|
||||
* still see the comment, since a like on a dump that has gone private is still
|
||||
* theirs to take back.
|
||||
*
|
||||
* @throws {APIException} 404 when the user doesn't like the comment.
|
||||
*/
|
||||
@@ -94,31 +95,28 @@ export function removeCommentLike(
|
||||
WHERE c.id = ?;`,
|
||||
).get(commentId) as { is_private: number } | undefined;
|
||||
const isPrivate = Boolean(dumpRow?.is_private);
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
return transaction(() => {
|
||||
const result = db.prepare(
|
||||
`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`,
|
||||
).run(commentId, userId);
|
||||
if (result.changes === 0) {
|
||||
db.exec("ROLLBACK;");
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Like not found");
|
||||
throw new APIException(404, "Like not found");
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE comments SET like_count = like_count - 1 WHERE id = ?;`,
|
||||
).run(commentId);
|
||||
const row = db.prepare(
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return { likeCount: row?.like_count ?? 0, isPrivate };
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
throw err;
|
||||
}
|
||||
).get(commentId) as { like_count: number };
|
||||
return { likeCount: row.like_count, isPrivate };
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the ids of every comment `userId` likes. */
|
||||
/**
|
||||
* Returns the ids of the comments `userId` likes. The WebSocket route sends
|
||||
* them in its `welcome` message, and the client shows those like buttons
|
||||
* pressed.
|
||||
*/
|
||||
export function getUserCommentLikes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
|
||||
@@ -126,7 +124,13 @@ 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. */
|
||||
/**
|
||||
* Returns a page of the users who like a comment, latest like first, for
|
||||
* `GET /api/comments/:commentId/likers`. It doesn't check that the caller may
|
||||
* see the comment: the route does that first, with `getDump`.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a user.
|
||||
*/
|
||||
export function getCommentLikers(
|
||||
commentId: string,
|
||||
page: number,
|
||||
@@ -146,11 +150,7 @@ export function getCommentLikers(
|
||||
).all(commentId, limit + 1, offset);
|
||||
|
||||
if (!rawRows.every(isUserRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed user data",
|
||||
);
|
||||
throw new APIException(500, "Malformed user data");
|
||||
}
|
||||
|
||||
return pageOf(rawRows.map(userRowToApi), limit);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Comments on dumps, threaded by `parent_id`.
|
||||
* Stores the comments on dumps, threaded by `parent_id`. The comment routes
|
||||
* call it and broadcast what it returns. It leaves checking that the caller
|
||||
* may see the dump to them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
import { type SQLOutputValue } from "node:sqlite";
|
||||
import { commentRowToApi, isCommentRow } from "../model/comment.ts";
|
||||
import { db } from "../db/connection.ts";
|
||||
import {
|
||||
@@ -17,31 +17,25 @@ 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`. */
|
||||
/** The select list of a `CommentRow`, from `comments c` joined to `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 = ?;`,
|
||||
).get(commentId);
|
||||
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
}
|
||||
if (!row) throw new APIException(404, "Comment not found");
|
||||
if (!isCommentRow(row)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed comment data",
|
||||
);
|
||||
throw new APIException(500, "Malformed comment data");
|
||||
}
|
||||
return commentRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the dump comment `commentId` is posted under.
|
||||
* Returns the id of the dump comment `commentId` is posted under. The likers
|
||||
* route uses it to check that the caller may see that dump.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment.
|
||||
*/
|
||||
@@ -50,14 +44,17 @@ export function getCommentDumpId(commentId: string): string {
|
||||
commentId,
|
||||
) as { dump_id: string } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
throw new APIException(404, "Comment not found");
|
||||
}
|
||||
return row.dump_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every comment on dump `dumpId`, deleted ones included, oldest
|
||||
* first.
|
||||
* Returns every comment on dump `dumpId`, oldest first, for
|
||||
* `GET /api/dumps/:dumpId/comments`. Deleted comments are included, emptied,
|
||||
* so the client can keep their replies in place.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a comment.
|
||||
*/
|
||||
export function getComments(dumpId: string): Comment[] {
|
||||
const rows = db.prepare(
|
||||
@@ -65,19 +62,19 @@ export function getComments(dumpId: string): Comment[] {
|
||||
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
|
||||
).all(dumpId);
|
||||
if (!rows.every(isCommentRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed comment data",
|
||||
);
|
||||
throw new APIException(500, "Malformed comment data");
|
||||
}
|
||||
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.
|
||||
* Posts a comment on dump `dumpId`, replying to `parentId` if given, for
|
||||
* `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the
|
||||
* users it mentions, unless the dump is private, and links the comment's
|
||||
* attachments and backlinks. The route has checked that the caller may see
|
||||
* the dump.
|
||||
*
|
||||
* @throws {APIException} 400 when `parentId` isn't a comment on the same dump.
|
||||
*/
|
||||
export function createComment(
|
||||
dumpId: string,
|
||||
@@ -85,6 +82,13 @@ export function createComment(
|
||||
body: string,
|
||||
parentId?: string,
|
||||
): Comment {
|
||||
if (parentId !== undefined) {
|
||||
const parent = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`)
|
||||
.get(parentId) as { dump_id: string } | undefined;
|
||||
if (parent?.dump_id !== dumpId) {
|
||||
throw new APIException(400, "The comment replied to isn't on this dump");
|
||||
}
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
db.prepare(
|
||||
@@ -98,22 +102,27 @@ export function createComment(
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
const comment = fetchComment(id);
|
||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||
dumpId,
|
||||
) as { title: string } | undefined;
|
||||
const dumpRow = db.prepare(
|
||||
`SELECT title, is_private FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { title: string; is_private: number } | undefined;
|
||||
notifyDumpOwnerNewComment(userId, id, dumpId);
|
||||
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
||||
linkAttachments(body, id);
|
||||
if (dumpRow && !dumpRow.is_private) {
|
||||
notifyMentions(userId, body, "comment", id, dumpRow.title, dumpId);
|
||||
}
|
||||
linkAttachments(body, "comment", id);
|
||||
relinkBacklinks("comment", id, dumpId, body);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a comment's body, as its author or a moderator (`canModerate`);
|
||||
* notifies anyone newly @mentioned, and rebinds its attachments and backlinks.
|
||||
* Replaces a comment's body, for `PATCH /api/comments/:commentId`.
|
||||
* `canModerate` lets someone other than the author do it. It notifies, in the
|
||||
* author's name, the users it mentions for the first time, unless the dump is
|
||||
* private, and relinks the attachments and backlinks. The route broadcasts the
|
||||
* comment unless `isPrivate`.
|
||||
*
|
||||
* @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.
|
||||
* deleted, 403 when the user is neither its author nor a moderator.
|
||||
*/
|
||||
export function updateComment(
|
||||
commentId: string,
|
||||
@@ -125,22 +134,14 @@ export function updateComment(
|
||||
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||
).get(commentId) as { dump_id: string; is_private: number } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
throw new APIException(404, "Comment not found");
|
||||
}
|
||||
const existing = fetchComment(commentId);
|
||||
if (existing.deleted) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Cannot edit a deleted comment",
|
||||
);
|
||||
throw new APIException(400, "Cannot edit a deleted comment");
|
||||
}
|
||||
if (existing.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to edit this comment",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to edit this comment");
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run(
|
||||
@@ -148,18 +149,21 @@ export function updateComment(
|
||||
now,
|
||||
commentId,
|
||||
);
|
||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||
row.dump_id,
|
||||
) as { title: string } | undefined;
|
||||
notifyMentions(
|
||||
requestingUserId,
|
||||
body,
|
||||
"comment",
|
||||
commentId,
|
||||
dumpRow?.title ?? "",
|
||||
row.dump_id,
|
||||
);
|
||||
linkAttachments(body, commentId);
|
||||
if (!row.is_private) {
|
||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||
row.dump_id,
|
||||
) as { title: string } | undefined;
|
||||
// A moderator's edit keeps the author's words, so the author mentions.
|
||||
notifyMentions(
|
||||
existing.userId,
|
||||
body,
|
||||
"comment",
|
||||
commentId,
|
||||
dumpRow?.title ?? "",
|
||||
row.dump_id,
|
||||
);
|
||||
}
|
||||
linkAttachments(body, "comment", commentId);
|
||||
relinkBacklinks("comment", commentId, row.dump_id, body);
|
||||
return {
|
||||
comment: fetchComment(commentId),
|
||||
@@ -169,11 +173,12 @@ 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.
|
||||
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
|
||||
* someone other than the author do it. The body is emptied and the backlinks
|
||||
* dropped, but the row stays so its replies keep their place. The route
|
||||
* broadcasts the deletion unless `isPrivate`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment, 401 when the user
|
||||
* @throws {APIException} 404 when there is no such comment, 403 when the user
|
||||
* is neither its author nor a moderator.
|
||||
*/
|
||||
export function deleteComment(
|
||||
@@ -185,15 +190,11 @@ export function deleteComment(
|
||||
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||
).get(commentId) as { dump_id: string; is_private: number } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
throw new APIException(404, "Comment not found");
|
||||
}
|
||||
const comment = fetchComment(commentId);
|
||||
if (comment.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete this comment",
|
||||
);
|
||||
throw new APIException(403, "Not authorized to delete this comment");
|
||||
}
|
||||
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
|
||||
commentId,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
* Posts, edits and deletes dumps, and reads them as each viewer may see them.
|
||||
* `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail
|
||||
* routes to read dumps. It broadcasts the changes to public dumps over the
|
||||
* WebSocket.
|
||||
*
|
||||
* The functions that change a dump don't check who asks: the routes do, before
|
||||
* calling them.
|
||||
* The functions that change a dump don't check who asks: the routes do first.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
CreateUrlDumpRequest,
|
||||
@@ -30,11 +30,7 @@ import {
|
||||
tryFetchRichContent,
|
||||
} from "./rich-content-service.ts";
|
||||
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||
import {
|
||||
broadcastDumpDeleted,
|
||||
broadcastDumpUpdated,
|
||||
broadcastNewDump,
|
||||
} from "./ws-service.ts";
|
||||
import { broadcastDumpChange } from "./ws-service.ts";
|
||||
import {
|
||||
notifyMentions,
|
||||
notifyUserFollowersNewDump,
|
||||
@@ -58,36 +54,34 @@ import {
|
||||
attachCategoryIds,
|
||||
setDumpCategories,
|
||||
} from "./category-service.ts";
|
||||
import { LIKE, likePattern } from "../lib/like.ts";
|
||||
import { parseRows } from "../model/rows.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Throws a 400 when a file dump's title or comment is too long. The multipart
|
||||
* routes that post and replace a file don't check them, unlike the JSON ones.
|
||||
*/
|
||||
function assertFileDumpText(title?: string, comment?: string): void {
|
||||
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
|
||||
);
|
||||
}
|
||||
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the title a link gets without a better one: its host. */
|
||||
/** Returns a link's host name, the title it gets when its page has none. */
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -97,8 +91,10 @@ 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.
|
||||
* Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched
|
||||
* from the page. A public dump is broadcast as `dump_created` and notified to
|
||||
* the poster's followers. Anyone @mentioned in the note is notified even when
|
||||
* the dump is private.
|
||||
*
|
||||
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
|
||||
* category doesn't exist.
|
||||
@@ -108,7 +104,7 @@ export async function createUrlDump(
|
||||
userId: string,
|
||||
): Promise<Dump> {
|
||||
if (!isValidHttpUrl(request.url)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||
throw new APIException(400, "Invalid URL");
|
||||
}
|
||||
assertCategoriesExist(request.categoryIds ?? []);
|
||||
|
||||
@@ -158,21 +154,23 @@ export async function createUrlDump(
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
};
|
||||
if (!isPrivate) {
|
||||
broadcastNewDump(dump);
|
||||
notifyUserFollowersNewDump(userId, dumpId, title);
|
||||
}
|
||||
broadcastDumpChange(null, dump);
|
||||
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
|
||||
if (request.comment) {
|
||||
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
||||
linkAttachments(request.comment, dumpId);
|
||||
// A private dump's mentions are notified if it goes public.
|
||||
if (!isPrivate) {
|
||||
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
||||
}
|
||||
linkAttachments(request.comment, "dump", dumpId);
|
||||
}
|
||||
relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? "");
|
||||
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.
|
||||
* Posts an uploaded file as `userId`, for `POST /api/dumps`, and stores it in
|
||||
* `DUMPS_DIR` under the dump's id. It is announced and notified as
|
||||
* {@link createUrlDump} does.
|
||||
*
|
||||
* @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.
|
||||
@@ -187,18 +185,10 @@ export async function createFileDump(
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`File type '${file.type}' is not allowed`,
|
||||
);
|
||||
throw new APIException(400, `File type '${file.type}' is not allowed`);
|
||||
}
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
throw new APIException(400, "File too large (max 50 MB)");
|
||||
}
|
||||
assertCategoriesExist(categoryIds);
|
||||
|
||||
@@ -234,7 +224,7 @@ export async function createFileDump(
|
||||
setDumpCategories(dumpId, dedupedCategoryIds);
|
||||
});
|
||||
} catch (err) {
|
||||
// Roll back the file if DB insert fails
|
||||
// Leave no file behind when the write or the insert fails.
|
||||
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
@@ -255,22 +245,24 @@ export async function createFileDump(
|
||||
isPrivate,
|
||||
categoryIds: dedupedCategoryIds,
|
||||
};
|
||||
if (!isPrivate) {
|
||||
broadcastNewDump(dump);
|
||||
notifyUserFollowersNewDump(userId, dumpId, finalTitle);
|
||||
}
|
||||
broadcastDumpChange(null, dump);
|
||||
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
|
||||
if (comment) {
|
||||
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
|
||||
linkAttachments(comment, dumpId);
|
||||
// A private dump's mentions are notified if it goes public.
|
||||
if (!isPrivate) {
|
||||
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
|
||||
}
|
||||
linkAttachments(comment, "dump", dumpId);
|
||||
}
|
||||
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns a file dump's version, which the app's file and thumbnail URLs carry
|
||||
* as `?v=`. It changes whenever the file may have, so the file and thumbnail
|
||||
* routes cache a URL naming the current one for good. The frontend's
|
||||
* `dumpFileVersion` must compute the same.
|
||||
*/
|
||||
export function dumpFileVersion(
|
||||
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
||||
@@ -281,9 +273,9 @@ export function dumpFileVersion(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
|
||||
* upload, once the upload is replaced or deleted. The route makes them again
|
||||
* from a new one.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
@@ -294,8 +286,8 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
|
||||
* check: use only when ownership is already enforced.
|
||||
* Returns the dump with id or slug `idOrSlug`, without its categories, or
|
||||
* throws a 404. It doesn't check privacy: the routes do, with {@link getDump}.
|
||||
*/
|
||||
function fetchDump(idOrSlug: string): Dump {
|
||||
const row = UUID_RE.test(idOrSlug)
|
||||
@@ -304,29 +296,31 @@ function fetchDump(idOrSlug: string): Dump {
|
||||
idOrSlug,
|
||||
);
|
||||
if (!row || !isDumpRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
throw new APIException(404, "Dump not found");
|
||||
}
|
||||
return dumpRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it.
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it. The routes
|
||||
* resolve every `:dumpId` with it before acting on the dump.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs.
|
||||
* @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) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
throw new APIException(404, "Dump not found");
|
||||
}
|
||||
attachCategoryIds([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.
|
||||
* Returns a page of the dumps `requestingUserId` may see whose title, note, or
|
||||
* previewed title or description contains `query`, newest first, with how many
|
||||
* match in all. `GET /api/search` calls it.
|
||||
*/
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
@@ -335,11 +329,11 @@ export function searchDumps(
|
||||
requestingUserId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
if (!query.trim()) return { items: [], total: 0 };
|
||||
const pattern = `%${query}%`;
|
||||
const pattern = likePattern(query);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const searchClause =
|
||||
`(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`;
|
||||
`(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`;
|
||||
const searchParams = [pattern, pattern, pattern, pattern] as const;
|
||||
|
||||
if (requestingUserId) {
|
||||
@@ -349,7 +343,7 @@ export function searchDumps(
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
|
||||
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
} else {
|
||||
@@ -359,15 +353,17 @@ export function searchDumps(
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
|
||||
).get(...searchParams) as { count: number } | undefined;
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps, newest first: the public ones, and the
|
||||
* requesting user's own.
|
||||
* Returns a page of the dumps `requestingUserId` may see, newest first, for the
|
||||
* index feed (`GET /api/dumps`).
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function listDumps(
|
||||
page: number,
|
||||
@@ -375,7 +371,6 @@ export function listDumps(
|
||||
requestingUserId?: string,
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
// Show public dumps + the requesting user's own private dumps
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
@@ -385,11 +380,7 @@ export function listDumps(
|
||||
).all(limit + 1, offset);
|
||||
|
||||
if (!rows || !rows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
|
||||
const items = rows.map(dumpRowToApi);
|
||||
@@ -398,16 +389,11 @@ export function listDumps(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* different share shape still counts as the same dump. Private dumps are
|
||||
* visible only to their owner, exactly as everywhere else.
|
||||
*
|
||||
* Returns a small display-only projection: this feeds a hint on the create
|
||||
* form, not a feed.
|
||||
* Returns the dumps `requestingUserId` may see that were posted with the same
|
||||
* link as `url`, newest first, for the create form's duplicate notice
|
||||
* (`GET /api/dumps/by-url`). Links are compared in their canonical form, so
|
||||
* tracking parameters, the scheme, `www.` or a YouTube share format don't hide
|
||||
* a repost.
|
||||
*/
|
||||
export function findDumpsByUrl(
|
||||
url: string,
|
||||
@@ -447,12 +433,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.
|
||||
* Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its
|
||||
* preview fetched and gives the dump its page's title, unless `request` sets
|
||||
* one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a
|
||||
* `dump_deleted` when it turns private and a `dump_created` when it turns
|
||||
* public. Anyone @mentioned in the note is notified.
|
||||
*
|
||||
* @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.
|
||||
* @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,
|
||||
@@ -465,7 +453,6 @@ export async function updateDump(
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// File dumps: title, comment and isPrivate are editable
|
||||
if (dump.kind === "file") {
|
||||
const title = request.title?.trim() || dump.title;
|
||||
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
|
||||
@@ -498,29 +485,27 @@ export async function updateDump(
|
||||
} else {
|
||||
attachCategoryIds([updatedDump]);
|
||||
}
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
// Going public is an arrival for everyone who couldn't see it before.
|
||||
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
if (updatedDump.comment) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
updatedDump.comment,
|
||||
"dump",
|
||||
dumpId,
|
||||
updatedDump.title,
|
||||
);
|
||||
linkAttachments(updatedDump.comment, dumpId);
|
||||
if (!updatedDump.isPrivate) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
updatedDump.comment,
|
||||
"dump",
|
||||
dumpId,
|
||||
updatedDump.title,
|
||||
);
|
||||
}
|
||||
linkAttachments(updatedDump.comment, "dump", dumpId);
|
||||
}
|
||||
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
// URL dumps
|
||||
const newUrl = request.url ?? dump.url!;
|
||||
|
||||
if (!isValidHttpUrl(newUrl)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||
throw new APIException(400, "Invalid URL");
|
||||
}
|
||||
|
||||
let { richContent, title } = dump;
|
||||
@@ -566,7 +551,7 @@ export async function updateDump(
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
throw new APIException(404, "Dump not found");
|
||||
}
|
||||
|
||||
if (request.categoryIds !== undefined) {
|
||||
@@ -577,34 +562,33 @@ export async function updateDump(
|
||||
attachCategoryIds([updatedDump]);
|
||||
}
|
||||
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
// Going public is an arrival for everyone who couldn't see it before.
|
||||
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
if (updatedDump.comment) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
updatedDump.comment,
|
||||
"dump",
|
||||
dumpId,
|
||||
updatedDump.title,
|
||||
);
|
||||
linkAttachments(updatedDump.comment, dumpId);
|
||||
if (!updatedDump.isPrivate) {
|
||||
notifyMentions(
|
||||
dump.userId,
|
||||
updatedDump.comment,
|
||||
"dump",
|
||||
dumpId,
|
||||
updatedDump.title,
|
||||
);
|
||||
}
|
||||
linkAttachments(updatedDump.comment, "dump", dumpId);
|
||||
}
|
||||
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
|
||||
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.
|
||||
* Replaces a file dump's upload and its comment, for
|
||||
* `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left
|
||||
* out stays as it is. Visibility is taken here so that a new file and a change
|
||||
* of visibility land together. The old file is put back if the database update
|
||||
* fails, and clients are told as {@link updateDump} tells them.
|
||||
*
|
||||
* @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.
|
||||
* @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,
|
||||
@@ -616,23 +600,15 @@ export async function replaceFileDump(
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`File type '${file.type}' is not allowed`,
|
||||
);
|
||||
throw new APIException(400, `File type '${file.type}' is not allowed`);
|
||||
}
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
throw new APIException(400, "File too large (max 50 MB)");
|
||||
}
|
||||
|
||||
const dump = fetchDump(dumpId);
|
||||
if (dump.kind !== "file") {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||
throw new APIException(400, "Not a file dump");
|
||||
}
|
||||
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
|
||||
|
||||
@@ -681,7 +657,6 @@ export async function replaceFileDump(
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Put the previous file back on DB failure
|
||||
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
|
||||
else await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
@@ -696,8 +671,10 @@ export async function replaceFileDump(
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
||||
linkAttachments(comment, dumpId);
|
||||
if (!finalPrivate) {
|
||||
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
||||
}
|
||||
linkAttachments(comment, "dump", dumpId);
|
||||
}
|
||||
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
|
||||
const updatedDump: Dump = {
|
||||
@@ -711,15 +688,16 @@ export async function replaceFileDump(
|
||||
isPrivate: finalPrivate,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s dumps, newest first, the private ones only with
|
||||
* `includePrivate`.
|
||||
* Returns a page of `userId`'s dumps, newest first, for
|
||||
* `GET /api/users/:username/dumps`. The route sets `includePrivate` when users
|
||||
* ask for their own.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getDumpsByUser(
|
||||
userId: string,
|
||||
@@ -733,11 +711,7 @@ export function getDumpsByUser(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(userId, limit + 1, offset);
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
@@ -745,8 +719,11 @@ export function getDumpsByUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
|
||||
* private ones only when they are the one asking.
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first, for
|
||||
* `GET /api/users/:username/votes`. Their own private dumps are included only
|
||||
* when they are the one asking.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getVotedDumpsByUser(
|
||||
userId: string,
|
||||
@@ -759,7 +736,6 @@ export function getVotedDumpsByUser(
|
||||
let rows: unknown[];
|
||||
|
||||
if (requestingUserId === userId) {
|
||||
// Own profile: include private dumps the user themselves voted on and owns.
|
||||
rows = db.prepare(
|
||||
`SELECT ${dumpCols}
|
||||
FROM dumps d
|
||||
@@ -778,11 +754,7 @@ export function getVotedDumpsByUser(
|
||||
}
|
||||
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
@@ -790,32 +762,26 @@ export function getVotedDumpsByUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL dump's preview again and replaces its title and preview.
|
||||
* Fetches a URL dump's preview again, bypassing the cache, and replaces its
|
||||
* title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`.
|
||||
*
|
||||
* @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.
|
||||
* @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);
|
||||
|
||||
if (dump.kind !== "url" || !dump.url) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Only URL dumps support metadata refresh",
|
||||
);
|
||||
throw new APIException(400, "Only URL dumps support metadata refresh");
|
||||
}
|
||||
|
||||
// A failed fetch yields a stub with no title and no thumbnail. Writing that
|
||||
// over a dump that already has good metadata would silently destroy it and
|
||||
// rename the dump to its bare hostname, so bail out instead — the caller
|
||||
// surfaces the error and nothing is lost.
|
||||
// A failed fetch yields a stub with no title or thumbnail. Written over the
|
||||
// dump's metadata, it would rename the dump to its host name.
|
||||
const { ok, content: richContent } = await tryFetchRichContent(dump.url, {
|
||||
fresh: true,
|
||||
});
|
||||
if (!ok) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Could not reach the page to refresh its metadata",
|
||||
);
|
||||
@@ -829,13 +795,15 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||
).run(row.title, row.rich_content, row.id);
|
||||
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one.
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one, for
|
||||
* the thumbnail route. It doesn't check privacy: the route calls
|
||||
* {@link getDump} first.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -849,7 +817,8 @@ export function getDumpThumbnailInfo(
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dump's new custom thumbnail, once its file is in place.
|
||||
* Records that a dump has a custom thumbnail of type `mime`, for
|
||||
* `PUT /api/dumps/:dumpId/thumbnail`, once the route has written the file.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -860,12 +829,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
dump.id,
|
||||
);
|
||||
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dump's custom thumbnail, and its file.
|
||||
* Removes a dump's custom thumbnail and its file, for
|
||||
* `DELETE /api/dumps/:dumpId/thumbnail`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -875,12 +845,13 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
.run(dump.id);
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a dump, its files and thumbnails, and announces it.
|
||||
* Deletes a dump with its files and thumbnails, for
|
||||
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -890,7 +861,7 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
||||
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
throw new APIException(404, "Dump not found");
|
||||
}
|
||||
|
||||
if (dump.kind === "file") {
|
||||
@@ -901,5 +872,5 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
}
|
||||
|
||||
broadcastDumpDeleted(dumpId);
|
||||
broadcastDumpChange(dump, null);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Sending mail through the SMTP server in {@link SMTPS_URL}.
|
||||
* Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset.
|
||||
* Its two callers, the welcome email at sign-up and the password reset link,
|
||||
* check {@link isEmailEnabled} first and don't wait for delivery.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,42 +10,43 @@ 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. */
|
||||
/**
|
||||
* One address, a comma-separated list of them, or an array, as nodemailer
|
||||
* takes them.
|
||||
*/
|
||||
export type EmailRecipient = string | string[];
|
||||
|
||||
/** A message to send. It needs a recipient, a subject, and a text or HTML body. */
|
||||
/**
|
||||
* Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or
|
||||
* HTML body; the callers send the same Markdown as both, rendered for `html`.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* Returned by {@link sendEmail} once the SMTP server has taken the message.
|
||||
* No caller reads it yet.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* Checks whether an SMTP server is configured. Without one, sign-up sends no
|
||||
* welcome email and a password reset request does nothing.
|
||||
*/
|
||||
export function isEmailEnabled(): boolean {
|
||||
return SMTPS_URL.length > 0;
|
||||
}
|
||||
@@ -76,19 +79,8 @@ function getTransporter(): 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`.
|
||||
* Sends `message` and resolves once the SMTP server has accepted it. The
|
||||
* transport is created on the first send.
|
||||
*
|
||||
* @throws {Error} When email is disabled, the message lacks a recipient,
|
||||
* subject or body, or the server refuses it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user