Compare commits
86 Commits
80f3292c8f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec73e9749 | ||
|
|
3e03626cc1 | ||
|
|
b92c674726 | ||
|
|
7d0a0e7534 | ||
|
|
51439f475d | ||
|
|
62e51f1622 | ||
|
|
eb4e64683c | ||
|
|
d54ae554a4 | ||
|
|
3e15e9a4f2 | ||
|
|
cbb770bff8 | ||
|
|
d2b7d21824 | ||
|
|
90fc281e90 | ||
|
|
6ab398e687 | ||
|
|
1bd5c924b0 | ||
|
|
de223baaa4 | ||
|
|
561cdf98b0 | ||
|
|
350e945eb1 | ||
|
|
dde0232acb | ||
|
|
7dd00068df | ||
|
|
28f910b00e | ||
|
|
ffc071af4c | ||
|
|
1d56c323a4 | ||
|
|
a2ec163ee5 | ||
|
|
7c00db2b84 | ||
|
|
308a8299f3 | ||
|
|
1c5a449d84 | ||
|
|
71df0268e3 | ||
|
|
9a215b0cc5 | ||
|
|
21f4db268e | ||
|
|
669e65a3a2 | ||
|
|
f1bc23a65e | ||
|
|
9608520675 | ||
|
|
6dfba07341 | ||
|
|
53ceb6bcbc | ||
|
|
683944ebe2 | ||
|
|
53515ce46f | ||
|
|
681170a517 | ||
|
|
93565cff0c | ||
|
|
8bcc005140 | ||
|
|
5f2fb4479a | ||
|
|
2c3ba6ad11 | ||
|
|
b4936cccf7 | ||
|
|
141e526de7 | ||
|
|
63803c552f | ||
|
|
52a23db504 | ||
|
|
83dc44050e | ||
|
|
66e402bd91 | ||
| c77e173767 | |||
|
|
a25500d833 | ||
|
|
c0d03e9aaa | ||
|
|
2d26d0aada | ||
|
|
fac0e2db28 | ||
|
|
c709b56911 | ||
|
|
c3594e4c27 | ||
|
|
134d1a0b97 | ||
|
|
5f239a144d | ||
|
|
708d113013 | ||
|
|
7713c5a1b5 | ||
|
|
256857f72e | ||
|
|
4ea192e0b1 | ||
|
|
2c031b0102 | ||
|
|
886216749e | ||
|
|
10c9cf4269 | ||
|
|
5acdf79c8d | ||
|
|
b9281da9c9 | ||
|
|
7531be300e | ||
|
|
a0c8b1d509 | ||
|
|
cf89c18eb9 | ||
|
|
45f39a959b | ||
|
|
1fda26a399 | ||
|
|
45e004b465 | ||
|
|
437eea0df3 | ||
|
|
d683ac0548 | ||
|
|
b9ff9c417e | ||
|
|
58c95dc4f3 | ||
|
|
0a16d3c96c | ||
|
|
4468438ba7 | ||
|
|
fd0d81fe66 | ||
|
|
7960d74c55 | ||
|
|
89014639cb | ||
|
|
f20ca83f0c | ||
|
|
5f0cf86fe6 | ||
|
|
f3f07ed1e3 | ||
|
|
2af64721c7 | ||
|
|
07ac4bc629 | ||
|
|
69d9bf899b |
@@ -1,3 +1,20 @@
|
||||
FROM denoland/deno:bin-2.9.0 AS deno
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node:20
|
||||
FROM denoland/deno:bin-2.9.7 AS deno
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node:26
|
||||
COPY --from=deno /deno /usr/local/bin/deno
|
||||
# The API draws thumbnails and waveforms with ffmpeg, in the workspace and in
|
||||
# the instances' services alike (compose.yaml), which don't get the features.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Playwright's Firefox and Chromium headless shell, with the system libraries
|
||||
# they need, for screenshots of the dev instances (`npm:playwright@1.63.0`): a
|
||||
# script importing another version looks for other builds. They live outside
|
||||
# the home directory, which a rebuild would otherwise leave without them.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
|
||||
RUN DENO_DIR=/tmp/deno deno run --no-lock -A npm:playwright@1.63.0 install \
|
||||
--with-deps firefox chromium-headless-shell \
|
||||
&& chmod -R a+rX /opt/ms-playwright \
|
||||
&& rm -rf /tmp/deno /var/lib/apt/lists/*
|
||||
# The services share Deno's cache through a volume (compose.yaml), which Docker
|
||||
# creates owned as this directory is, so it must exist and be node's.
|
||||
RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache
|
||||
|
||||
57
.devcontainer/compose.yaml
Normal file
57
.devcontainer/compose.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
# The dev environment: the workspace VS Code attaches to (`dev`), and one
|
||||
# service per dev instance of gerbeur (dev/instances.ts), which start with it.
|
||||
# Each instance service runs dev/start.ts for its own instance, which derives
|
||||
# its ports, URLs, database and uploads from its entry there; a new instance is
|
||||
# an entry there and a service here.
|
||||
#
|
||||
# Every service shares the host's network, as the workspace always did: an
|
||||
# instance's URLs are localhost ones, which the browser and the other
|
||||
# instances' servers must both reach.
|
||||
#
|
||||
# From the workspace's terminal (docker-outside-of-docker):
|
||||
# docker compose -p gerbeur-dev logs -f musique
|
||||
# docker compose -p gerbeur-dev restart gerbeur
|
||||
# docker compose -p gerbeur-dev stop gerbeur musique # then `deno task dev`
|
||||
name: gerbeur-dev
|
||||
|
||||
x-workspace: &workspace
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
network_mode: host
|
||||
user: node
|
||||
working_dir: /workspaces/gerbeur
|
||||
volumes:
|
||||
- ..:/workspaces/gerbeur:cached
|
||||
# Shared, so that each service doesn't download the jsr and npm packages
|
||||
# again.
|
||||
- deno-cache:/home/node/.cache/deno
|
||||
|
||||
x-instance: &instance
|
||||
<<: *workspace
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
install:
|
||||
condition: service_completed_successfully
|
||||
|
||||
services:
|
||||
dev:
|
||||
<<: *workspace
|
||||
command: sleep infinity
|
||||
|
||||
# Installs the packages and draws the icons once, before any instance
|
||||
# starts, so that the instances don't do it at the same time.
|
||||
install:
|
||||
<<: *workspace
|
||||
command: sh -c "deno install && deno task icons public"
|
||||
|
||||
gerbeur:
|
||||
<<: *instance
|
||||
command: deno run --env-file -A dev/start.ts gerbeur
|
||||
|
||||
musique:
|
||||
<<: *instance
|
||||
command: deno run --env-file -A dev/start.ts musique
|
||||
|
||||
volumes:
|
||||
deno-cache:
|
||||
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get@sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71",
|
||||
"integrity": "sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71"
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0",
|
||||
"integrity": "sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0"
|
||||
},
|
||||
"ghcr.io/khannurien/devcontainer-features/claude-code:1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9",
|
||||
"integrity": "sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9"
|
||||
},
|
||||
"ghcr.io/khannurien/devcontainer-features/opencode:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/khannurien/devcontainer-features/opencode@sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574",
|
||||
"integrity": "sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574"
|
||||
},
|
||||
"ghcr.io/khannurien/devcontainer-features/rtk:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/khannurien/devcontainer-features/rtk@sha256:4a60b3a1d1b64904de3d856c61cdf7e5943e3c74d114609787dc88d9db7c788f",
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
{
|
||||
"name": "Deno",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"name": "gerbeur",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
"runArgs": ["--network=host"],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
// The workspace and the dev instances of gerbeur, one service each: see
|
||||
// compose.yaml. The instances start with the container.
|
||||
"dockerComposeFile": "compose.yaml",
|
||||
"service": "dev",
|
||||
"workspaceFolder": "/workspaces/gerbeur",
|
||||
"shutdownAction": "stopCompose",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
@@ -26,9 +19,11 @@
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {}
|
||||
// The docker CLI, on the host's daemon, for the instances' logs and
|
||||
// restarts (compose.yaml). Docker's own packages: the base image is Debian
|
||||
// trixie, which has no moby-cli.
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"moby": false
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
|
||||
42
.env.example
42
.env.example
@@ -48,19 +48,45 @@ GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
||||
GERBEUR_JWT_SECRET=
|
||||
|
||||
# SMTP connection URL used by the email service (notifications, invites, etc.)
|
||||
# SMTP connection URL for outgoing email: the welcome email, password reset
|
||||
# links and the weekly/monthly digests. Leave empty to send no email at all.
|
||||
# Example: smtps://username:password@smtp.example.com:465
|
||||
GERBEUR_SMTPS_URL=
|
||||
|
||||
# Sender address used in outgoing emails (e.g. no-reply@example.com)
|
||||
# Required when GERBEUR_SMTPS_URL is set.
|
||||
# Sender of every email, as an address or "Name <address>"
|
||||
# (e.g. gerbeur <no-reply@example.com>).
|
||||
# Required when GERBEUR_SMTPS_URL is set: the server refuses to start without it.
|
||||
GERBEUR_FROM_EMAIL=
|
||||
|
||||
# Markdown body for the account creation welcome email.
|
||||
# Supports {{username}} and {{site_name}} placeholders.
|
||||
# Defaults to a built-in template when unset.
|
||||
# Use \n for line breaks in single-line .env values, or use a quoted multiline block.
|
||||
GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!"
|
||||
# Language of every email: en (default) or fr. The interface follows each
|
||||
# visitor's browser, which the server never sees, so emails use the instance's.
|
||||
GERBEUR_EMAIL_LOCALE=en
|
||||
|
||||
# The instance's style: smooth (default), brutalist, geocities, nyt, kanagawa
|
||||
# or melange. Visitors and accounts see it until they pick one, and the welcome
|
||||
# and password reset emails are drawn in it; a digest uses its reader's own
|
||||
# style. Applied at runtime; changing it needs a restart of both the API and Vite.
|
||||
GERBEUR_DEFAULT_STYLE=smooth
|
||||
|
||||
# How often, in minutes, the /community page's figures are recomputed.
|
||||
GERBEUR_COMMUNITY_REFRESH_MINUTES=15
|
||||
|
||||
# Set to 0 to keep users of other gerbeur instances from signing in here, which
|
||||
# also signs out those already in until it is set back, or to keep this
|
||||
# instance's users from signing in to others, which signs them out there.
|
||||
# GERBEUR_FEDERATION_INCOMING=1
|
||||
# GERBEUR_FEDERATION_OUTGOING=1
|
||||
|
||||
# Set to 1 to let federated sign-in reach other instances on private addresses
|
||||
# (localhost included) and over plain http, so that two instances on one
|
||||
# machine can trust each other in development. Never in production.
|
||||
# GERBEUR_FEDERATION_ALLOW_PRIVATE=1
|
||||
|
||||
# Markdown body of the welcome email sent on account creation, replacing the
|
||||
# built-in one (written in GERBEUR_EMAIL_LOCALE). Supports {{username}} and
|
||||
# {{site_name}}. Use \n for line breaks in single-line .env values, or a
|
||||
# quoted multiline block.
|
||||
# GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!"
|
||||
|
||||
# ── Frontend (Vite) ───────────────────────────────────────────────────────────
|
||||
# These must be prefixed with VITE_ to be exposed to the client bundle.
|
||||
|
||||
302
API.md
Normal file
302
API.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# gerbeur's URLs
|
||||
|
||||
Every page and every API endpoint follows one of two rules. This file summarizes
|
||||
them. Each router's module doc in `api/routes/` lists its own endpoints in full,
|
||||
and a route change updates both. `shared/paths.ts` builds and parses the page
|
||||
paths.
|
||||
|
||||
## The two rules
|
||||
|
||||
**Pages.** The path is a hierarchy. `@name` is a user, and `@name@host` a user
|
||||
whose account lives on another instance. Any other word under the root is a
|
||||
category or an app page. Under a user, each segment is a fixed tab name, and
|
||||
under `dumps` or `playlists`, a slug. A view of a page is one more segment. A
|
||||
default view is the bare path, with no redirect. A category is a segment where
|
||||
the page is the category's own, a dump category's feed at the root (`/music`)
|
||||
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
|
||||
narrows a list on another page, a profile's or a search's, it is `?category=`,
|
||||
of the kind that list holds. The order of a list of playlists is `?sort=`, the
|
||||
default order being the bare path, and the playlists page's list of the viewer's
|
||||
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
|
||||
place of the current history entry; picking a category, or opening another page
|
||||
or an editor, adds one.
|
||||
|
||||
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
|
||||
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
|
||||
sniffing is needed. A read by page path mirrors the page:
|
||||
`/api/users/@bob/dumps/<slug>`. The caller's own things live under `/api/me`.
|
||||
Media is a sub-resource. A lookup by another key is a query filter. Each concept
|
||||
has one word, the same as in pages.
|
||||
|
||||
## Pages
|
||||
|
||||
| Path | Views | Access | Page |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------- |
|
||||
| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
|
||||
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
|
||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
|
||||
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either; `?mine=1` lists the viewer's own, signed in only | anyone | `Playlists` |
|
||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
|
||||
| `/notifications` | | signed in | `Notifications` |
|
||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||
| `/settings` | | signed in | `Settings` |
|
||||
| `/manage` | | `category:manage`, `federation:manage` | `Manage` |
|
||||
| `/login`, `/register?token=` | `/login?next=<path>` comes back to a page | signed out | `UserLogin`, `UserRegister` |
|
||||
| `/authorize?client_id=…` | | signed in, else via `/login?next=` | `Authorize` |
|
||||
| `/federation/callback?code=&state=` | | anyone | `FederationCallback` |
|
||||
| `/reset-password` | `/reset-password?token=`, from the emailed link, sets the new password | anyone | `ResetPassword` |
|
||||
| `/unsubscribe?token=` | | anyone | `Unsubscribe` |
|
||||
|
||||
`/?share_url=` opens the create form on a link shared from Android, `?chat=open`
|
||||
opens the chat over any page, `?chat=<message id>` on that message, and
|
||||
`#comment-<id>` scrolls a dump's page to a comment. A signed-in page opened
|
||||
signed out replaces itself with `/login?next=<the page>`.
|
||||
|
||||
## Names
|
||||
|
||||
- **Slugs** of categories, dumps and playlists are lowercase letters and digits
|
||||
in words joined by single hyphens (`shared/slug.ts`). A category's is at most
|
||||
50 characters, a dump's or a playlist's at most 60.
|
||||
- **A dump's or a playlist's slug** is made from its title when it is created,
|
||||
unique among its owner's items of that kind: a clash gets `-2`, `-3`. It
|
||||
changes only when someone edits it: the edit forms propose `slugify(newTitle)`
|
||||
when the title changes. Its owner may change it 3 times a day; moderators
|
||||
(`dump:moderate`, `playlist:moderate`) may edit any slug, at any time.
|
||||
- **A username** is 1 to 32 letters, digits and underscores, unique in any case.
|
||||
A page writes it in its stored case, and another case still resolves. Users
|
||||
can't rename themselves: those with `user:manage` rename anyone, from
|
||||
`/@<name>/manage`.
|
||||
- **A user from another instance** is `name@host` here: their name at home and
|
||||
their home's host, with its port if it has one (`alice@music.example`). Their
|
||||
name is their home's to change, which leaves the old handle an alias here too.
|
||||
Mentions write the whole handle. A handle with this instance's own host, as
|
||||
another instance writes a user from here (`bob@gerbeur.example`), names the
|
||||
local `bob`, in a path, an API key or a mention.
|
||||
- **Aliases.** A slug or a name left behind stays an alias for good, which
|
||||
always names the item or user itself, so redirects never chain. Nobody else
|
||||
can take one. An item can take back one of its own. Deleting an item frees its
|
||||
aliases.
|
||||
- **Redirects.** A page reached by an alias, or by a name in another case, gets
|
||||
the item from the API under its current path and replaces its URL. The server
|
||||
answers the same path with a 301 to crawlers and full page loads.
|
||||
- **Reserved words.** A category can't take a word the root already uses:
|
||||
`hot new journal follow search login register notifications reset-password
|
||||
unsubscribe settings manage community playlists authorize federation api ws
|
||||
assets emojibase`
|
||||
(`api/lib/reserved-slugs.ts`). Dump and playlist slugs reserve nothing, and no
|
||||
slug has an `@`.
|
||||
|
||||
## API
|
||||
|
||||
Every JSON endpoint answers the envelope of `shared/api.ts`:
|
||||
`{ "success": true, "data": … }`, or
|
||||
`{ "success": false, "error": { "code",
|
||||
"message" } }` with the matching HTTP
|
||||
status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
|
||||
`?limit=` (20 by default, 100 at most). "Signed in" means a session token as
|
||||
`Authorization: Bearer …`. Media can't send a header, so a private file,
|
||||
thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
`?token=`.
|
||||
|
||||
An image the API serves, an avatar, a thumbnail, a cover, an attachment or a
|
||||
remote image, takes `?w=` and answers a WebP copy scaled down to that width,
|
||||
snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider
|
||||
than the image; without it, the image as it was uploaded, a dump's thumbnail
|
||||
alone being scaled to the widest by default. `?f=compat` asks for the copy as a
|
||||
JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made
|
||||
of goes out as it is. JSON answers of a kilobyte or more are compressed, in
|
||||
brotli or gzip, for a client that accepts it.
|
||||
|
||||
### me
|
||||
|
||||
- `GET /api/me` — signed in. The caller's own user.
|
||||
- `PATCH /api/me` — signed in. Changes the description and email.
|
||||
- `GET /api/me/media-token` — signed in. A media token.
|
||||
- `GET`, `PATCH /api/me/settings` — signed in. The caller's settings.
|
||||
- `PUT /api/me/password` — signed in, with the current one. A new password, and
|
||||
fresh tokens.
|
||||
- `PUT /api/me/avatar` — signed in. A new avatar (multipart).
|
||||
- `GET /api/me/playlists?contains=:dumpId` — signed in. The caller's playlists,
|
||||
and whether each holds the dump.
|
||||
- `GET /api/me/follow` — signed in. The ids of the users and playlists followed.
|
||||
- `PUT`, `DELETE /api/me/follow/users/:id`, `/api/me/follow/playlists/:id` —
|
||||
signed in. Follows or stops following.
|
||||
- `GET /api/me/notifications` — signed in. The caller's notifications.
|
||||
- `POST /api/me/notifications/read` — signed in. Marks them all read.
|
||||
- `GET /api/me/grants` — signed in. The other instances the caller's account
|
||||
signs in to.
|
||||
- `DELETE /api/me/grants/:host` — signed in. Withdraws one, ending the caller's
|
||||
sessions there.
|
||||
|
||||
An account from another instance changes its password, email, avatar and name
|
||||
there: those endpoints answer it 400.
|
||||
|
||||
### users
|
||||
|
||||
- `POST /api/users` — with an invite. Signs up.
|
||||
- `GET /api/users?q=` — anyone. Up to 8 users whose name starts with `q`.
|
||||
- `GET /api/users/:key` — anyone. A profile.
|
||||
- `GET /api/users/:key/avatar` — anyone. The avatar.
|
||||
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
|
||||
to their owner only. Their lists; `?category=` narrows one to a category of
|
||||
its kind, and `?sort=` orders the playlists as `GET /api/playlists` does.
|
||||
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
|
||||
to their owner only. An item by its page path, by an alias too.
|
||||
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
|
||||
follow.
|
||||
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
|
||||
- `GET /api/users/:key/home?path=` — anyone. Redirects to a page of a remote
|
||||
user's home instance, their profile there by default.
|
||||
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
|
||||
role, or anyone's name, theirs included.
|
||||
|
||||
### dumps
|
||||
|
||||
- `POST /api/dumps` — signed in. Posts a link (JSON) or a file (multipart).
|
||||
- `GET /api/dumps` — anyone. The feed; `?category=<slug>` narrows it, and
|
||||
`?url=` answers the dumps of the same link instead.
|
||||
- `GET /api/dumps?follow=users|playlists` — signed in. The feed's `follow` tab:
|
||||
the dumps of the users, or of the playlists, the caller follows; `?category=`
|
||||
narrows it too.
|
||||
- `GET /api/dumps/:id` — anyone who may see it. A dump.
|
||||
- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who upvoted
|
||||
it, the dumps linked to or from it, the playlists holding it.
|
||||
- `PUT /api/dumps/:id` — its poster, `dump:moderate`. Title, slug, note,
|
||||
visibility, categories, link.
|
||||
- `POST /api/dumps/:id/metadata` — the same. Fetches a link's preview again.
|
||||
- `DELETE /api/dumps/:id` — the same. Deletes it.
|
||||
- `GET`, `PUT /api/dumps/:id/file` — the file; a new one, by its poster or
|
||||
`dump:moderate`.
|
||||
- `GET`, `PUT`, `DELETE /api/dumps/:id/thumbnail` — the thumbnail; a custom one,
|
||||
set or removed by its poster or `dump:moderate`.
|
||||
- `GET /api/dumps/:id/waveform` — anyone who may see it. An audio file's
|
||||
waveform, `{ "peaks" }`, for the player to seek on.
|
||||
- `GET`, `POST /api/dumps/:id/comments` — anyone who may see the dump; posting
|
||||
signed in. Its comments.
|
||||
|
||||
### playlists
|
||||
|
||||
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||
playlist categories it names.
|
||||
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
|
||||
narrows it to a playlist category, and `?sort=updated|new|dumps` orders it:
|
||||
the last to take a dump first (the default), the newest, or the fullest. The
|
||||
caller's own private dumps count, and date their playlists, for them alone.
|
||||
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
|
||||
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
|
||||
slug, description, visibility, categories; deletion.
|
||||
- `POST`, `DELETE /api/playlists/:id/dumps/:dumpId` — the same. Adds or removes
|
||||
a dump.
|
||||
- `PUT /api/playlists/:id/order` — the same. Reorders its dumps.
|
||||
- `GET`, `PUT /api/playlists/:id/cover` — the cover; a new one, by its owner or
|
||||
`playlist:moderate`.
|
||||
|
||||
### comments
|
||||
|
||||
- `PATCH`, `DELETE /api/comments/:id` — its author, `comment:moderate`. Edits or
|
||||
deletes a comment.
|
||||
- `GET /api/comments/:id/likers` — anyone. Who likes it.
|
||||
|
||||
### categories
|
||||
|
||||
- `GET /api/categories` — anyone. Every category, dump and playlist ones apart:
|
||||
each has a `kind`, and its slug is unique within its kind.
|
||||
- `POST /api/categories`, `PUT`, `DELETE /api/categories/:id` —
|
||||
`category:manage`. Changes them.
|
||||
|
||||
### community
|
||||
|
||||
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
|
||||
new members, new public playlists and totals over the last week (by default)
|
||||
or month, as of the server's last refresh.
|
||||
- `GET /api/community/feed` — signed in. The community feed: the 12 latest
|
||||
things done in public, newest first (a public dump posted, a public playlist
|
||||
created, a comment or an upvote on a public dump, someone joining), and the
|
||||
last week's totals and most upvoted dump, as of the server's last refresh. A
|
||||
dump has one comment event and one upvote event at most, its latest. Every
|
||||
reader gets the same feed.
|
||||
|
||||
### chat
|
||||
|
||||
- `GET /api/chat?before=&after=` — signed in. A page of messages. The chat
|
||||
itself goes over `/ws`.
|
||||
- A message from a moderator or an admin (`chat:everyone`) that says `@everyone`
|
||||
notifies every user who doesn't have the chat open, and comes flagged
|
||||
`mentionsEveryone`. No account may be named `everyone`.
|
||||
|
||||
### invites
|
||||
|
||||
- `POST /api/invites` — signed in. A new invite.
|
||||
- `GET /api/invites/:token` — anyone. Whether it still works.
|
||||
|
||||
### sessions, password-resets, unsubscribe
|
||||
|
||||
- `POST /api/sessions` — anyone. Signs in.
|
||||
- `POST /api/password-resets` — anyone. Emails a reset link.
|
||||
- `PUT /api/password-resets/:token` — with the link's token. A new password.
|
||||
- `POST /api/unsubscribe?token=` — with a digest's token. Turns it off. Its v3
|
||||
path, `POST /api/users/unsubscribe`, stays for good, since sent emails'
|
||||
`List-Unsubscribe` headers name it.
|
||||
|
||||
### federation, oauth, .well-known
|
||||
|
||||
Federated sign-in (`shared/federation.ts`): a user signs in here with their
|
||||
account on another gerbeur instance, their home, by OpenID Connect's
|
||||
authorization code flow with PKCE, with tokens signed by each instance's Ed25519
|
||||
key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and
|
||||
`/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`,
|
||||
`GET /api/oauth/clients` and `POST /api/oauth/codes` do. An instance is named by
|
||||
its issuer, its API's origin, whose host is the one in its users' handles. Where
|
||||
`instance` is asked for, the origin of its pages works too: their
|
||||
`/.well-known/openid-configuration` names the issuer, whose own metadata must
|
||||
name those pages back as where its `/authorize` is.
|
||||
|
||||
- `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone.
|
||||
The instance's metadata and public keys, bare JSON: the one exception to the
|
||||
`/api/` rule, at the paths the standard fixes.
|
||||
- `POST /api/federation/logins` — anyone. Starts a sign-in with an account on
|
||||
another instance; one neither trusted nor already home to someone here needs
|
||||
an invite.
|
||||
- `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in.
|
||||
- `POST /api/federation/events` — another instance. Its signed news of one of
|
||||
its users here: a new name, email or avatar, or the end of their sessions. A
|
||||
4xx tells it to give up on the event, a 429 or a 5xx to try again.
|
||||
- `GET /api/federation/peers` — anyone the trusted instances,
|
||||
`federation:manage` every listed one.
|
||||
- `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` —
|
||||
`federation:manage`. Trusts or blocks an instance, or takes it off the lists.
|
||||
- `GET /api/oauth/clients?client_id=` — signed in. The instance asking to sign
|
||||
the caller in.
|
||||
- `POST /api/oauth/codes` — signed in. The caller's consent, and a code.
|
||||
- `POST /api/oauth/tokens` — another instance, form-encoded. Trades a code for
|
||||
an identity token, bare JSON.
|
||||
|
||||
### search
|
||||
|
||||
- `GET /api/search?q=` — anyone. A page of dumps, and up to 20 users and
|
||||
playlists. It finds both kinds of item at once, so `?category=` narrows the
|
||||
dumps and `?playlistCategory=` the playlists.
|
||||
|
||||
### attachments
|
||||
|
||||
- `POST /api/attachments` — signed in. Stores an image.
|
||||
- `GET /api/attachments/:id` — anyone who may see the text holding it.
|
||||
|
||||
### remote
|
||||
|
||||
- `GET /api/remote/preview?url=` — signed in. A link's preview.
|
||||
- `GET /api/remote/image?url=` — anyone. A remote image through this site.
|
||||
- `GET /api/remote/bandcamp?url=` — anyone. A Bandcamp page's tracks.
|
||||
- `GET /api/remote/youtube?list=` — anyone. A YouTube playlist's tracklist.
|
||||
|
||||
### /ws
|
||||
|
||||
- `GET /ws?token=` — anyone; a session token signs it in. The WebSocket of live
|
||||
updates and the chat (`shared/ws.ts`).
|
||||
@@ -11,6 +11,9 @@ COPY index.html ./
|
||||
COPY public/ ./public/
|
||||
COPY src/ ./src/
|
||||
COPY shared/ ./shared/
|
||||
# The build extracts and compiles the catalogs, the emails' messages included.
|
||||
COPY locales/ ./locales/
|
||||
COPY api/mail/ ./api/mail/
|
||||
|
||||
# VITE_API_* are only needed when the API lives on a different host than the frontend.
|
||||
ARG VITE_API_PROTOCOL
|
||||
@@ -35,6 +38,10 @@ RUN deno install
|
||||
COPY api/ ./api/
|
||||
COPY shared/ ./shared/
|
||||
COPY --from=builder /app/dist/ ./dist/
|
||||
COPY --from=builder /app/locales/ ./locales/
|
||||
# The emails read their colors, fonts and radii from the site's stylesheets.
|
||||
COPY src/tokens.css ./src/
|
||||
COPY src/themes/ ./src/themes/
|
||||
|
||||
# Persistent data: database and uploaded/generated files must be mounted as volumes.
|
||||
VOLUME ["/app/api/sql", "/app/api/uploads"]
|
||||
|
||||
175
README.md
175
README.md
@@ -3,8 +3,9 @@
|
||||
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.
|
||||
WebSocket layer handles live presence, vote counts, and notifications, and
|
||||
weekly and monthly email digests sum up what happened. The UI is localized
|
||||
(English and French) and ships multiple visual themes.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -30,36 +31,59 @@ cp .env.example .env
|
||||
|
||||
### Run
|
||||
|
||||
In the devcontainer, the dev instances listed in
|
||||
[`dev/instances.ts`](dev/instances.ts) start with the container, each a service
|
||||
of [`.devcontainer/compose.yaml`](.devcontainer/compose.yaml): `gerbeur` at
|
||||
[http://localhost:3000](http://localhost:3000), `musique` at
|
||||
[http://localhost:3001](http://localhost:3001). They run side by side so that
|
||||
signing in from one instance to another can be tried on one machine, and from
|
||||
the workspace's terminal:
|
||||
|
||||
```sh
|
||||
deno task dev
|
||||
docker compose -p gerbeur-dev logs -f musique # follow an instance
|
||||
docker compose -p gerbeur-dev restart gerbeur # restart one
|
||||
```
|
||||
|
||||
This starts both the API server (port 8000, with file watching) and the Vite dev
|
||||
server (port 3000) concurrently.
|
||||
Outside the devcontainer, or with those services stopped, `deno task dev` starts
|
||||
them all, and `deno task dev gerbeur` only those named.
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). On first run a default
|
||||
`admin` / `admin` account is created — change the password immediately.
|
||||
Each instance has an API server, restarted on change, and a Vite server, and its
|
||||
output is prefixed with its name. They follow one rule
|
||||
([`dev/start.ts`](dev/start.ts)): instance `<name>` keeps its database in
|
||||
`api/sql/<name>.db` and its uploads in `api/uploads/<name>/`, reads `.env`, and
|
||||
takes what sets it apart (ports, site name, emoji, style) from its entry. A new
|
||||
instance is an entry in `dev/instances.ts` and a service in `compose.yaml`.
|
||||
|
||||
On first run each instance creates a default `admin` / `admin` account — change
|
||||
the password immediately. To let one instance's users sign in to another, trust
|
||||
it on the other's `/manage`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
See [`.env.example`](.env.example) for the full list with descriptions. Key
|
||||
variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
||||
| `GERBEUR_PUBLIC_URL` | Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | `http://localhost:GERBEUR_PORT` |
|
||||
| `GERBEUR_PORT` | Internal port Oak listens on | `8000` |
|
||||
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
|
||||
| `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` |
|
||||
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) |
|
||||
| `GERBEUR_SITE_NAME` | Site name used in the browser tab, app header, OG meta tags, and emails | `gerbeur` |
|
||||
| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`) | unset |
|
||||
| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset |
|
||||
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
|
||||
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` |
|
||||
| `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` |
|
||||
| `VITE_API_PORT` | API port baked into the frontend bundle | `8000` |
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
||||
| `GERBEUR_PUBLIC_URL` | Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs | `http://localhost:GERBEUR_PORT` |
|
||||
| `GERBEUR_PORT` | Internal port Oak listens on | `8000` |
|
||||
| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` |
|
||||
| `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` |
|
||||
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) |
|
||||
| `GERBEUR_SITE_NAME` | Site name used in the browser tab, app header, OG meta tags, and emails | `gerbeur` |
|
||||
| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`); unset sends no email | unset |
|
||||
| `GERBEUR_FROM_EMAIL` | Sender of every email — required when `GERBEUR_SMTPS_URL` is set, or the server won't start | unset |
|
||||
| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` |
|
||||
| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` |
|
||||
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
|
||||
| `GERBEUR_FEDERATION_INCOMING` | `0` keeps users of other instances from signing in here, and signs out those already in | `1` |
|
||||
| `GERBEUR_FEDERATION_OUTGOING` | `0` keeps this instance's users from signing in to other instances, and signs them out there | `1` |
|
||||
| `GERBEUR_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset |
|
||||
| `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` |
|
||||
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` |
|
||||
| `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` |
|
||||
| `VITE_API_PORT` | API port baked into the frontend bundle | `8000` |
|
||||
|
||||
## Production
|
||||
|
||||
@@ -118,18 +142,50 @@ example.com {
|
||||
}
|
||||
```
|
||||
|
||||
### Several instances
|
||||
|
||||
Each instance is its own deployment, with its own database, uploads, admins and
|
||||
domain, but its users can sign in to the others with their account, and the
|
||||
other way round. A user from `music.example` is `@alice@music.example` on the
|
||||
instances they sign in to. Their password, email, avatar and name stay on their
|
||||
home instance, and the others follow its changes.
|
||||
|
||||
Every instance is ready for it: nothing to configure, as long as
|
||||
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
|
||||
can reach each other. Users and admins may name an instance by the address of
|
||||
its API or of its pages: when the frontend runs on a host of its own
|
||||
(`GERBEUR_FRONTEND_URL`), that host must pass `/.well-known/` on to the API, as
|
||||
the Vite dev server does. A handle carries the API's host. Either way can be
|
||||
turned off: `GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances,
|
||||
and `GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin
|
||||
decides who comes in, on `/manage`:
|
||||
|
||||
- users of a **trusted** instance sign in directly, from a button on the login
|
||||
page;
|
||||
- users of a **blocked** instance can't, and those already in are signed out;
|
||||
- users of **any other** instance join with an invite, from the register page,
|
||||
and sign in without one afterwards.
|
||||
|
||||
`deno task dev` runs several instances on one machine (see [Run](#run)), with
|
||||
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
|
||||
plain http on localhost. Never set it in production. The first, `gerbeur`, takes
|
||||
no users of the others, whose users sign in to each other.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
dev/ # The dev instances (instances.ts) and their launcher (start.ts), for `deno task dev`
|
||||
shared/ # Imported by both halves: API and socket types, limits, permissions
|
||||
test/ # Tests of shared/ (Deno): test/<module>.test.ts
|
||||
locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages
|
||||
api/
|
||||
main.ts # Entry point — Oak application, middleware, routes
|
||||
config.ts # Environment variables
|
||||
middleware/ # Oak middleware: errors, auth, OG tags
|
||||
routes/ # One router per URL prefix: HTTP + WebSocket, no SQL
|
||||
routes/ # One router per API resource: HTTP + WebSocket, no SQL
|
||||
services/ # Business logic and every SQL query
|
||||
remote/ # Everything that fetches from other sites
|
||||
mail/ # One template per email, and the layout they share
|
||||
providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
|
||||
model/ # Per table: row type, row guard, row ↔ API mappers
|
||||
validators/ # Per domain: request body guards
|
||||
@@ -142,22 +198,31 @@ api/
|
||||
migrations/ # Versioned schema migrations
|
||||
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
|
||||
sql/
|
||||
gerbeur.db # SQLite database (not committed)
|
||||
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
|
||||
gerbeur.db # SQLite database (not committed); <name>.db per dev instance
|
||||
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
|
||||
src/ # React frontend (Vite)
|
||||
config/ # API base URL, feed tabs, player mode
|
||||
config/ # API base URL, default feed tab, player mode, instance settings
|
||||
pages/ # One component per route, nothing else
|
||||
components/ # React components only (.tsx)
|
||||
contexts/ # FooContext.ts + FooProvider.tsx pairs
|
||||
hooks/ # Every hook (useFoo.ts)
|
||||
utils/ # Every non-React module: pure helpers, storage, API glue
|
||||
test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
|
||||
locales/ # Lingui message catalogues (en, fr)
|
||||
themes/ # Per-theme CSS files
|
||||
i18n.ts # Lingui runtime setup
|
||||
public/ # Static assets (favicon, manifest, service worker)
|
||||
```
|
||||
|
||||
### URLs
|
||||
|
||||
Every page and API path follows one of two rules, which [API.md](API.md) states,
|
||||
along with every page, the slug and username rules and every endpoint. Pages are
|
||||
a hierarchy under `@name` for users and plain words for categories and app pages
|
||||
(`/@bob/dumps/never-gonna`, `/music/hot`), built and parsed by
|
||||
`shared/paths.ts`. API resources are `/api/<plural>/<key>`, the caller's own
|
||||
under `/api/me`. A route change updates its router's module doc and API.md
|
||||
together.
|
||||
|
||||
Whatever crosses the wire is defined once, in `shared/`: the API envelope
|
||||
(`shared/api.ts`), each domain's types and request bodies (`shared/dump.ts`,
|
||||
`shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation
|
||||
@@ -169,8 +234,9 @@ The backend is split by layer, one kind of module per folder, and each file is
|
||||
named after its domain: the dumps are `routes/dumps.ts`,
|
||||
`services/dump-service.ts`, `model/dump.ts` and `validators/dump.ts`. Routes
|
||||
parse and answer, services hold the logic and the SQL, `remote/` is the only
|
||||
place that fetches from other sites. Tests mirror the source one file per
|
||||
module, so the tests of `services/chat-service.ts` are in
|
||||
place that fetches from other sites, and `mail/` renders each email that
|
||||
`services/email-service.ts` sends. Tests mirror the source one file per module,
|
||||
so the tests of `services/chat-service.ts` are in
|
||||
`test/services/chat-service.test.ts`.
|
||||
|
||||
### Comments
|
||||
@@ -188,9 +254,20 @@ one of:
|
||||
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".
|
||||
**A doc opens with the role, then the mechanism, then the details.** Its first
|
||||
sentence says what part of the app the thing serves or what problem it solves,
|
||||
so a reader who has never seen it knows why it exists. How it does that (what
|
||||
feeds it, what reads it, how it's split) comes next, and the details last. Each
|
||||
sentence makes sense given only the ones before it. Not "Declares the three
|
||||
contexts `WSProvider` fills from the WebSocket. They are split so that a feed
|
||||
doesn't re-render with every chat message", which leaves the reason for last,
|
||||
but "Live updates from the server reach the pages through three contexts, split
|
||||
so that a feed doesn't re-render with every chat message. `WSProvider` fills
|
||||
them from its WebSocket."
|
||||
|
||||
Once the role is said, 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.
|
||||
@@ -209,13 +286,13 @@ and locals. None of these count as a doc:
|
||||
|
||||
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.
|
||||
count. A module doc is a few lines at most. It opens with the module's role in
|
||||
the app, then says how it plays it, 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
|
||||
@@ -234,16 +311,16 @@ 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/`).
|
||||
Its index page is the module doc of `mod.ts` at the root, which introduces the
|
||||
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`.
|
||||
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.
|
||||
`api/`, `shared/` and `src/` but the tests. Its index page is the module doc of
|
||||
`mod.ts` at the root, which introduces the 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`. 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`,
|
||||
|
||||
122
api/config.ts
122
api/config.ts
@@ -1,38 +1,65 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets a deployment configure the server through the `GERBEUR_*` environment
|
||||
* variables, which it reads 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
|
||||
*/
|
||||
|
||||
import { BASE_STYLE, isStyleName, type StyleName } from "../shared/theme.ts";
|
||||
import { isLocale, type Locale, SOURCE_LOCALE } from "../shared/locale.ts";
|
||||
|
||||
/** The port the server listens on (`GERBEUR_PORT`, default 8000). */
|
||||
export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
||||
/**
|
||||
* 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.
|
||||
* The `smtps://` URL of the server `email-service.ts` sends mail through
|
||||
* (`GERBEUR_SMTPS_URL`). When it is empty, no email is sent: sign-up sends no
|
||||
* welcome, a password reset request does nothing, and there are no digests.
|
||||
*/
|
||||
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
|
||||
"";
|
||||
/** The `From` address of the welcome and password reset emails. */
|
||||
/**
|
||||
* Whether the instance sends email, which is whether {@link SMTPS_URL} is
|
||||
* set. `email-service.ts` opens its transport by it, and index.html tells the
|
||||
* client, whose settings show the digest switches disabled without it.
|
||||
*/
|
||||
export const EMAIL_ENABLED: boolean = SMTPS_URL.length > 0;
|
||||
/**
|
||||
* The `From` address of every email (`GERBEUR_FROM_EMAIL`), as an address or
|
||||
* as `Name <address>`. `email-service.ts` refuses to load without it when
|
||||
* {@link SMTPS_URL} is set.
|
||||
*/
|
||||
export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
|
||||
"";
|
||||
|
||||
const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}!
|
||||
|
||||
Hi **{{username}}**,
|
||||
|
||||
Your account has been created successfully. Welcome aboard!`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* (`GERBEUR_WELCOME_EMAIL_BODY`), or null for the built-in one in the
|
||||
* {@link EMAIL_LOCALE}. `api/mail/welcome.ts` replaces `{{site_name}}` and
|
||||
* `{{username}}` in it.
|
||||
*/
|
||||
export const WELCOME_EMAIL_BODY: string =
|
||||
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() ||
|
||||
DEFAULT_WELCOME_EMAIL_BODY;
|
||||
export const WELCOME_EMAIL_BODY: string | null =
|
||||
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || null;
|
||||
/**
|
||||
* The language of every email (`GERBEUR_EMAIL_LOCALE`). The interface picks
|
||||
* each visitor's language in their browser, which the server never learns, so
|
||||
* the emails take the instance's. A value that isn't a locale means the
|
||||
* source one, English.
|
||||
*/
|
||||
export const EMAIL_LOCALE: Locale = (() => {
|
||||
const locale = Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim();
|
||||
return isLocale(locale) ? locale : SOURCE_LOCALE;
|
||||
})();
|
||||
/**
|
||||
* The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a
|
||||
* visitor or their account picks one, through index.html, and the emails that
|
||||
* aren't a user's own (the welcome and the password reset) are drawn in it. A
|
||||
* value that isn't a style means {@link BASE_STYLE}.
|
||||
*/
|
||||
export const DEFAULT_STYLE: StyleName = (() => {
|
||||
const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim();
|
||||
return isStyleName(style) ? style : BASE_STYLE;
|
||||
})();
|
||||
/**
|
||||
* The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`).
|
||||
* The server refuses to start without it.
|
||||
@@ -55,10 +82,10 @@ export const PUBLIC_URL: string =
|
||||
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
|
||||
`http://localhost:${PORT}`;
|
||||
/**
|
||||
* 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.
|
||||
* Points password reset links and the Open Graph tags' page URLs at the
|
||||
* frontend when it isn't served from {@link PUBLIC_URL}, as with a separate
|
||||
* Vite server, and allows it as an origin. It is `GERBEUR_FRONTEND_URL`,
|
||||
* without a trailing slash.
|
||||
*/
|
||||
export const FRONTEND_URL: string =
|
||||
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") ||
|
||||
@@ -93,9 +120,15 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
* thumbnails route generates from file dumps.
|
||||
*/
|
||||
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
|
||||
/**
|
||||
* Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes
|
||||
* as they are first asked for, each at its upload's path with its width. It
|
||||
* can be emptied at any time: the copies are made again.
|
||||
*/
|
||||
export const SCALED_DIR = `${UPLOADS_DIR}/scaled`;
|
||||
|
||||
/**
|
||||
* The largest image upload accepted, in bytes, for avatars, covers, thumbnails
|
||||
* Caps the size of an image upload, 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;
|
||||
@@ -137,6 +170,13 @@ export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
|
||||
*/
|
||||
export const UNUSED_INVITES_RETENTION_DAYS = 7;
|
||||
|
||||
/**
|
||||
* How often, in minutes, the `/community` page's figures are recomputed
|
||||
* (`GERBEUR_COMMUNITY_REFRESH_MINUTES`), 15 by default.
|
||||
*/
|
||||
export const COMMUNITY_REFRESH_MINUTES: number =
|
||||
Number(Deno.env.get("GERBEUR_COMMUNITY_REFRESH_MINUTES")) || 15;
|
||||
|
||||
/**
|
||||
* The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG
|
||||
* tags, the icons' manifest and the emails.
|
||||
@@ -188,3 +228,37 @@ export const ALLOWED_ORIGINS: string[] = Array.from(
|
||||
...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Lets federated sign-in reach instances on private addresses, `localhost`
|
||||
* among them, and over plain http (`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`), so
|
||||
* that two instances on one machine can trust each other in development.
|
||||
* Off, `remote/gerbeur-instance.ts` reaches only public https instances, since
|
||||
* an identity is only as safe as the connection it came over.
|
||||
*/
|
||||
export const FEDERATION_ALLOW_PRIVATE: boolean =
|
||||
Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1";
|
||||
/**
|
||||
* Which ways federated sign-in goes, each on unless set to `0`.
|
||||
*
|
||||
* - `incoming` (`GERBEUR_FEDERATION_INCOMING`): users of other instances sign
|
||||
* in here. Off, the login and register pages offer no other instance,
|
||||
* `/manage` lists only the blocked ones, which this instance's users can't
|
||||
* sign in to (and none with `outgoing` off too), `federation-service.ts`
|
||||
* refuses their sign-ins, and the accounts that came from one are signed
|
||||
* out (`getSessionState`) until it is on again. Their homes' events are
|
||||
* still applied, so that none is lost meanwhile.
|
||||
* - `outgoing` (`GERBEUR_FEDERATION_OUTGOING`): this instance's users sign in
|
||||
* to others. Off, `identity-provider-service.ts` lets no instance sign them
|
||||
* in, and its metadata says so, which the others check before sending
|
||||
* anyone here to sign in. The settings page lists no instances, since at
|
||||
* startup `main.ts` withdraws every grant they gave, which signs them out
|
||||
* there.
|
||||
*
|
||||
* index.html tells the client both. An object, so that the tests can turn one
|
||||
* off for a while.
|
||||
*/
|
||||
export const FEDERATION: { incoming: boolean; outgoing: boolean } = {
|
||||
incoming: Deno.env.get("GERBEUR_FEDERATION_INCOMING")?.trim() !== "0",
|
||||
outgoing: Deno.env.get("GERBEUR_FEDERATION_OUTGOING")?.trim() !== "0",
|
||||
};
|
||||
|
||||
@@ -15,8 +15,8 @@ import { runStartupTasks } from "./startup.ts";
|
||||
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.
|
||||
* Serves every query of the server, as its 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
|
||||
|
||||
@@ -25,6 +25,13 @@ import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.
|
||||
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";
|
||||
import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts";
|
||||
import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts";
|
||||
import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
|
||||
import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts";
|
||||
import { up as up0023MentionEveryone } from "./migrations/0023_mention_everyone.ts";
|
||||
import { up as up0024Federation } from "./migrations/0024_federation.ts";
|
||||
import { up as up0025PlaylistCategories } from "./migrations/0025_playlist_categories.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -32,10 +39,10 @@ interface Migration {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists 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 that 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 },
|
||||
@@ -62,6 +69,13 @@ const MIGRATIONS: Migration[] = [
|
||||
name: "0018_attachment_resource_type",
|
||||
up: up0018AttachmentResourceType,
|
||||
},
|
||||
{ name: "0019_user_settings", up: up0019UserSettings },
|
||||
{ name: "0020_digest_schedule", up: up0020DigestSchedule },
|
||||
{ name: "0021_page_urls", up: up0021PageUrls },
|
||||
{ name: "0022_reserve_community", up: up0022ReserveCommunity },
|
||||
{ name: "0023_mention_everyone", up: up0023MentionEveryone },
|
||||
{ name: "0024_federation", up: up0024Federation },
|
||||
{ name: "0025_playlist_categories", up: up0025PlaylistCategories },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets a poster upload a thumbnail that replaces the fetched one. It adds
|
||||
* `dumps.custom_thumbnail_mime`, which holds the uploaded image's type; NULL
|
||||
* means the dump has none.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets a dump page list the dumps related to it. It adds `dump_backlinks`,
|
||||
* which records the links from a dump's note or comments to other dumps and
|
||||
* which backlink-service fills, and an index on `dumps.url` to find the dumps
|
||||
* an external link was posted as.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Adds `users.role`, which replaces the `is_admin` flag so that moderators can
|
||||
* exist between users and admins. 0005 then drops the flag.
|
||||
* Makes room for moderators between users and admins. It adds `users.role`,
|
||||
* which replaces the `is_admin` flag, and 0005 then drops the flag.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets a chat message reply to another, through `chat_messages.reply_to_id`.
|
||||
* The column 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
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Makes the YouTube dumps posted with a start time, before the YouTube provider
|
||||
* kept it, start there instead of at 0:00. `rich_content.embedUrl` is computed
|
||||
* when a dump is posted, and it used to drop the source URL's `t` (`?t=1265`,
|
||||
* `?t=1h2m3s`); this adds the `start` parameter back to the stored embed URLs.
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
* Stops older dumps from showing a site's icon as their thumbnail. 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, by moving their icons out of
|
||||
* `rich_content.thumbnailUrl` into the new `faviconUrl`.
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets the create form warn that a link was already dumped. It adds
|
||||
* `dumps.url_canonical`, the key the hint looks a URL up by, and fills it for
|
||||
* every URL dump already posted.
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets a password change end every session opened before it. Tokens are
|
||||
* stateless JWTs, so this adds `users.tokens_valid_after`, and lib/auth.ts
|
||||
* refuses a token issued before that time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Serves an attachment only to those who may see what it was pasted into, and
|
||||
* deletes it once that is gone. It 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
|
||||
*/
|
||||
|
||||
28
api/db/migrations/0019_user_settings.ts
Normal file
28
api/db/migrations/0019_user_settings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Gives each user settings stored on the server, so that they follow the
|
||||
* account across browsers and the server can read them: the digests need the
|
||||
* reader's choice to receive them and their style. `user_settings` holds one
|
||||
* row per user who changed anything; a user without one has the defaults.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing users get no row, so they start with
|
||||
* the defaults, digests on, like a new account.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
style TEXT,
|
||||
color_scheme TEXT,
|
||||
digest_weekly INTEGER NOT NULL DEFAULT 1,
|
||||
digest_monthly INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
}
|
||||
52
api/db/migrations/0020_digest_schedule.ts
Normal file
52
api/db/migrations/0020_digest_schedule.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Lets `digest-service.ts` know when each user's next digest is due.
|
||||
* `digest_schedule` holds, for each user and cadence they receive, the start
|
||||
* of the period their next digest covers; it is due once a full period has
|
||||
* passed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
// A frozen copy of digest-service's periods: a migration must keep doing
|
||||
// what it did when it shipped, whatever the service later becomes.
|
||||
const PERIOD_DAYS = { weekly: 7, monthly: 30 } as const;
|
||||
|
||||
/** Spreads `userId` over `[0, days)` days, the same way each run. */
|
||||
function offsetOf(userId: string, days: number): number {
|
||||
let hash = 2166136261;
|
||||
for (const ch of userId) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619);
|
||||
return ((hash >>> 0) % (days * 24)) * 3_600_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Every user present gets both cadences, with a
|
||||
* first period that ends at some hour of the coming week or month, picked
|
||||
* from their id: the digests start trickling out after the deploy instead of
|
||||
* all leaving a week later. Users created later are scheduled by the service
|
||||
* when it first sees them.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS digest_schedule (
|
||||
user_id TEXT NOT NULL,
|
||||
cadence TEXT NOT NULL,
|
||||
window_start TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, cadence),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
const users = db.prepare(`SELECT id FROM users;`).all() as { id: string }[];
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO digest_schedule (user_id, cadence, window_start) VALUES (?, ?, ?);`,
|
||||
);
|
||||
const now = Date.now();
|
||||
for (const { id } of users) {
|
||||
for (const [cadence, days] of Object.entries(PERIOD_DAYS)) {
|
||||
const start = now - days * DAY_MS + offsetOf(id, days);
|
||||
insert.run(id, cadence, new Date(start).toISOString());
|
||||
}
|
||||
}
|
||||
}
|
||||
239
api/db/migrations/0021_page_urls.ts
Normal file
239
api/db/migrations/0021_page_urls.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Moves the site to v4's page URLs, where dumps and playlists live under their
|
||||
* owner (`/@bob/dumps/<slug>`) and are named by an editable slug alone. A
|
||||
* slug was `slugify(title)-<8 hex of the id>`, unique site-wide; it is now the
|
||||
* bare `slugify(title)`, unique in its owner's space for its kind, and every
|
||||
* slug an item or a user leaves behind stays an alias that redirects
|
||||
* (`dump_slug_aliases`, `playlist_slug_aliases`, `user_aliases`).
|
||||
*
|
||||
* v3 URLs aren't redirected. Instead this rewrites the links to this site's
|
||||
* pages in what users wrote: dump notes, comments, chat messages, and
|
||||
* playlist and profile descriptions.
|
||||
*
|
||||
* SQLite can't make `slug` NOT NULL without rebuilding `dumps` and
|
||||
* `playlists`, which other tables reference, so the column stays nullable
|
||||
* and every writer sets it; the unique index is what the services rely on.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { slugify } from "../../../shared/slug.ts";
|
||||
import {
|
||||
dumpPath,
|
||||
feedPath,
|
||||
playlistPath,
|
||||
type ProfileTab,
|
||||
searchPath,
|
||||
userPath,
|
||||
} from "../../../shared/paths.ts";
|
||||
import { type SlugKind, uniqueSlug } from "../../lib/slugify.ts";
|
||||
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
|
||||
import { isSelfHost } from "../../lib/self-url.ts";
|
||||
|
||||
/** An item's new path, by its v3 slug and by its id. */
|
||||
type PathsByKey = Map<string, string>;
|
||||
|
||||
/**
|
||||
* Gives every dump or playlist of `kind` the bare slug of its title, unique
|
||||
* per owner, the oldest first so it keeps the bare one. Returns each one's new
|
||||
* path under both keys a v3 link may have used: its old slug and its id.
|
||||
*/
|
||||
function reslug(db: DatabaseSync, kind: SlugKind): PathsByKey {
|
||||
const table = kind === "dump" ? "dumps" : "playlists";
|
||||
const rows = db.prepare(
|
||||
`SELECT t.id, t.user_id, t.title, t.slug, u.username
|
||||
FROM ${table} t JOIN users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at, t.id;`,
|
||||
).all() as {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
slug: string | null;
|
||||
username: string;
|
||||
}[];
|
||||
// Cleared first, so that an old slug doesn't take a new one's place.
|
||||
db.exec(`UPDATE ${table} SET slug = NULL;`);
|
||||
const set = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
|
||||
const paths: PathsByKey = new Map();
|
||||
const build = kind === "dump" ? dumpPath : playlistPath;
|
||||
for (const row of rows) {
|
||||
const slug = uniqueSlug(db, kind, row.user_id, slugify(row.title));
|
||||
set.run(slug, row.id);
|
||||
const path = build({ ownerUsername: row.username, slug });
|
||||
paths.set(row.id, path);
|
||||
if (row.slug) paths.set(row.slug, path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Where each v3 profile tab went: a tab, the overview, or a page of its own. */
|
||||
const PROFILE_TAB_V4: Record<string, ProfileTab | "" | "/settings"> = {
|
||||
dumps: "",
|
||||
playlists: "playlists",
|
||||
followed: "follow",
|
||||
invitees: "invitees",
|
||||
settings: "/settings",
|
||||
manage: "manage",
|
||||
};
|
||||
/** The v3 full lists under a profile, which are v4 tabs. */
|
||||
const PROFILE_LISTS = new Set<string>(["dumps", "upvoted", "playlists"]);
|
||||
|
||||
/**
|
||||
* Matches a link to a v3 page, its origin being optional: an absolute one is
|
||||
* this site's only when its host is, a relative one only as a Markdown link's
|
||||
* target. The path stops before a query or a fragment, which stay as they are.
|
||||
*/
|
||||
const V3_LINK_RE =
|
||||
/(https?:\/\/([^/\s)\]<>"']+))?(\/(?:dumps|playlists|users|~|search\/~)\/[^\s)\]<>"'?#]+)/g;
|
||||
|
||||
/**
|
||||
* Returns the v4 path of the v3 page at `path`, or null when it names nothing
|
||||
* known, which then stays as written: a deleted dump's link is dead either way.
|
||||
*/
|
||||
function v4PathOf(
|
||||
path: string,
|
||||
dumps: PathsByKey,
|
||||
playlists: PathsByKey,
|
||||
username: (name: string) => string | undefined,
|
||||
): string | null {
|
||||
let segments: string[];
|
||||
try {
|
||||
segments = path.split("/").slice(1).filter(Boolean).map(
|
||||
decodeURIComponent,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const [root, key, ...rest] = segments;
|
||||
switch (root) {
|
||||
case "dumps": {
|
||||
const target = dumps.get(key);
|
||||
if (!target) return null;
|
||||
if (rest.length === 0) return target;
|
||||
return rest.length === 1 && rest[0] === "edit" ? `${target}/edit` : null;
|
||||
}
|
||||
case "playlists":
|
||||
return rest.length === 0 ? playlists.get(key) ?? null : null;
|
||||
case "users": {
|
||||
const name = username(key) ?? key;
|
||||
if (rest.length === 0) return userPath(name);
|
||||
if (rest.length === 1 && PROFILE_LISTS.has(rest[0])) {
|
||||
return userPath(name, rest[0] as ProfileTab);
|
||||
}
|
||||
if (rest.length === 2 && rest[0] === "~" && rest[1] in PROFILE_TAB_V4) {
|
||||
const tab = PROFILE_TAB_V4[rest[1]];
|
||||
if (tab === "/settings") return tab;
|
||||
return tab ? userPath(name, tab) : userPath(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "~":
|
||||
if (rest.length > 0) return null;
|
||||
return key === "followed"
|
||||
? feedPath(undefined, "follow")
|
||||
: ["hot", "new", "journal"].includes(key)
|
||||
? feedPath(undefined, key as "hot")
|
||||
: null;
|
||||
case "search":
|
||||
// `/search/~/users`, split as "search", "~", "users".
|
||||
return key === "~" && (rest[0] === "users" || rest[0] === "playlists") &&
|
||||
rest.length === 1
|
||||
? searchPath(rest[0])
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the links to v3 pages of this site in the text column `column` of
|
||||
* `table`, in every row that has one.
|
||||
*/
|
||||
function rewriteLinks(
|
||||
db: DatabaseSync,
|
||||
table: string,
|
||||
column: string,
|
||||
toV4: (path: string) => string | null,
|
||||
): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, ${column} AS text FROM ${table} WHERE ${column} IS NOT NULL;`,
|
||||
).all() as { id: string; text: string }[];
|
||||
const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE id = ?;`);
|
||||
for (const { id, text } of rows) {
|
||||
const rewritten = text.replace(
|
||||
V3_LINK_RE,
|
||||
(match, origin: string | undefined, host: string, path: string, at) => {
|
||||
if (origin ? !isSelfHost(host) : text[at - 1] !== "(") return match;
|
||||
const v4 = toV4(path);
|
||||
return v4 ? `${origin ?? ""}${v4}` : match;
|
||||
},
|
||||
);
|
||||
if (rewritten !== text) update.run(rewritten, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. On a database made from schema.sql the tables
|
||||
* and indexes exist and there is nothing to re-slug or rewrite.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dump_slug_aliases (
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, slug)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dump_slug_aliases_dump
|
||||
ON dump_slug_aliases(dump_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS playlist_slug_aliases (
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, slug)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_slug_aliases_playlist
|
||||
ON playlist_slug_aliases(playlist_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||
username TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (username)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_aliases_lower
|
||||
ON user_aliases(lower(username));
|
||||
CREATE INDEX IF NOT EXISTS idx_user_aliases_user
|
||||
ON user_aliases(user_id, created_at);
|
||||
DROP INDEX IF EXISTS idx_dumps_slug;
|
||||
DROP INDEX IF EXISTS idx_playlists_slug;
|
||||
`);
|
||||
|
||||
const dumps = reslug(db, "dump");
|
||||
const playlists = reslug(db, "playlist");
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dumps_user_slug
|
||||
ON dumps(user_id, slug);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_user_slug
|
||||
ON playlists(user_id, slug);
|
||||
`);
|
||||
|
||||
renameReservedCategories(db);
|
||||
|
||||
const findUser = db.prepare(
|
||||
`SELECT username FROM users WHERE lower(username) = lower(?);`,
|
||||
);
|
||||
const username = (name: string) =>
|
||||
(findUser.get(name) as { username: string } | undefined)?.username;
|
||||
const toV4 = (path: string) => v4PathOf(path, dumps, playlists, username);
|
||||
for (
|
||||
const [table, column] of [
|
||||
["dumps", "comment"],
|
||||
["comments", "body"],
|
||||
["chat_messages", "body"],
|
||||
["playlists", "description"],
|
||||
["users", "description"],
|
||||
]
|
||||
) rewriteLinks(db, table, column, toV4);
|
||||
}
|
||||
14
api/db/migrations/0022_reserve_community.ts
Normal file
14
api/db/migrations/0022_reserve_community.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Reserves `community` at the URL root for the community page, renaming a
|
||||
* category that had it as its slug.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
|
||||
|
||||
/** Run once by `runMigrations`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
renameReservedCategories(db);
|
||||
}
|
||||
74
api/db/migrations/0023_mention_everyone.ts
Normal file
74
api/db/migrations/0023_mention_everyone.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Prepares the chat's `@everyone`: it adds `chat_messages.mentions_everyone`,
|
||||
* and frees the name `everyone`, which no account may have any more.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { EVERYONE, mentionsEveryone } from "../../../shared/mention.ts";
|
||||
import { ROLE_PERMISSIONS } from "../../../shared/permissions.ts";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. The messages that a moderator or an admin
|
||||
* already sent with `@everyone` get the flag, so that editing one later
|
||||
* doesn't notify everyone of an old message; none is notified now. A user
|
||||
* named `everyone`, in any case, is renamed to the first free `everyone_2`,
|
||||
* `everyone_3`…, with no alias left behind, and an alias `everyone` is
|
||||
* dropped. Both are logged.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === "mentions_everyone")) {
|
||||
db.exec(
|
||||
`ALTER TABLE chat_messages ADD COLUMN mentions_everyone INTEGER NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
}
|
||||
|
||||
const roles = Object.entries(ROLE_PERMISSIONS)
|
||||
.filter(([, perms]) => perms.includes("chat:everyone"))
|
||||
.map(([role]) => role);
|
||||
const messages = db.prepare(
|
||||
`SELECT m.id, m.body FROM chat_messages m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.mentions_everyone = 0
|
||||
AND u.role IN (${roles.map(() => "?").join(", ")})
|
||||
AND m.body LIKE '%@%';`,
|
||||
).all(...roles) as { id: string; body: string }[];
|
||||
const flag = db.prepare(
|
||||
`UPDATE chat_messages SET mentions_everyone = 1 WHERE id = ?;`,
|
||||
);
|
||||
for (const m of messages) {
|
||||
if (mentionsEveryone(m.body)) flag.run(m.id);
|
||||
}
|
||||
|
||||
const taken = (name: string) =>
|
||||
!!db.prepare(
|
||||
`SELECT 1 FROM users WHERE lower(username) = lower(?)
|
||||
UNION ALL SELECT 1 FROM user_aliases WHERE lower(username) = lower(?);`,
|
||||
).get(name, name);
|
||||
const user = db.prepare(
|
||||
`SELECT id, username FROM users WHERE lower(username) = ?;`,
|
||||
).get(EVERYONE) as { id: string; username: string } | undefined;
|
||||
if (user) {
|
||||
let n = 2;
|
||||
while (taken(`${EVERYONE}_${n}`)) n++;
|
||||
const renamed = `${EVERYONE}_${n}`;
|
||||
db.prepare(`UPDATE users SET username = ? WHERE id = ?;`).run(
|
||||
renamed,
|
||||
user.id,
|
||||
);
|
||||
console.log(
|
||||
`[migrate] user "${user.username}" is now "${renamed}": @${EVERYONE} is reserved`,
|
||||
);
|
||||
}
|
||||
const alias = db.prepare(
|
||||
`DELETE FROM user_aliases WHERE lower(username) = ? RETURNING user_id;`,
|
||||
).get(EVERYONE) as { user_id: string } | undefined;
|
||||
if (alias) {
|
||||
console.log(
|
||||
`[migrate] the former name "${EVERYONE}" of user ${alias.user_id} is dropped: @${EVERYONE} is reserved`,
|
||||
);
|
||||
}
|
||||
}
|
||||
105
api/db/migrations/0024_federation.ts
Normal file
105
api/db/migrations/0024_federation.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Prepares federated sign-in, by which a user signs in here with their
|
||||
* account on another gerbeur instance, and the other way round. It adds the
|
||||
* instance's signing key, the link from a local account to the one it stands
|
||||
* for elsewhere, the instances an admin trusts or blocks, the state of the
|
||||
* sign-ins in progress on both sides, the grants users gave other instances,
|
||||
* and the events owed to them.
|
||||
*
|
||||
* `/authorize` and `/federation/callback` become pages, so their first
|
||||
* segments are reserved.
|
||||
*
|
||||
* An account from another instance has no password here: its `password_hash`
|
||||
* is `''`, which no password verifies. Emails stay unique among local accounts
|
||||
* only, since the same person may also have a local account, or come from two
|
||||
* instances, with one address.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Nothing exists to convert, since every account
|
||||
* so far is local, but `authorize` and `federation` become the pages of a
|
||||
* sign-in, so a category that had one as its slug is renamed.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS federation_keys (
|
||||
kid TEXT PRIMARY KEY,
|
||||
private_jwk TEXT NOT NULL,
|
||||
public_jwk TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
picture TEXT,
|
||||
synced_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_identities_subject
|
||||
ON user_identities(issuer, subject);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_peers (
|
||||
issuer TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_logins (
|
||||
state TEXT PRIMARY KEY,
|
||||
issuer TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
invite_token TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
client_name TEXT,
|
||||
granted_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_outbox_next
|
||||
ON federation_outbox(next_attempt_at);
|
||||
`);
|
||||
|
||||
const emailIndex = db.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE name = 'idx_users_email_lower';`,
|
||||
).get() as { sql: string } | undefined;
|
||||
if (!emailIndex?.sql.includes("WHERE")) {
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_users_email_lower;
|
||||
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email))
|
||||
WHERE password_hash != '';
|
||||
`);
|
||||
}
|
||||
renameReservedCategories(db);
|
||||
}
|
||||
110
api/db/migrations/0025_playlist_categories.ts
Normal file
110
api/db/migrations/0025_playlist_categories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Gives playlists categories of their own, a set apart from the dumps': each
|
||||
* category gets a `kind`, `dump` or `playlist`, its slug and name now unique
|
||||
* within its kind only, and `playlist_categories` files playlists under them.
|
||||
*
|
||||
* `/playlists` becomes the page of every public playlist, so its first segment
|
||||
* is reserved.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Every existing category becomes a dump
|
||||
* category, filing the same dumps.
|
||||
*
|
||||
* SQLite can't change a table's UNIQUE constraints in place, so `categories`
|
||||
* is rebuilt. `foreign_keys` can't be switched off inside the runner's
|
||||
* transaction, and dropping `categories` would then empty `dump_categories`
|
||||
* through its cascade, so both are read, dropped child first, made anew as in
|
||||
* schema.sql, and refilled. A database made from schema.sql already has the
|
||||
* `kind` column and is left as it is.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const columns = new Set(
|
||||
(db.prepare(`PRAGMA table_info(categories);`).all() as { name: string }[])
|
||||
.map((c) => c.name),
|
||||
);
|
||||
|
||||
if (!columns.has("kind")) {
|
||||
const categories = db.prepare(
|
||||
`SELECT id, slug, name, position, created_at, updated_at FROM categories;`,
|
||||
).all() as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}[];
|
||||
const links = db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories;`,
|
||||
).all() as { dump_id: string; category_id: string }[];
|
||||
|
||||
db.exec(`DROP TABLE dump_categories;`);
|
||||
db.exec(`DROP TABLE categories;`);
|
||||
|
||||
db.exec(
|
||||
`CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
UNIQUE (kind, slug),
|
||||
UNIQUE (kind, name)
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE TABLE dump_categories (
|
||||
dump_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dump_id, category_id),
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);`,
|
||||
);
|
||||
|
||||
const insertCategory = db.prepare(
|
||||
`INSERT INTO categories (id, kind, slug, name, position, created_at, updated_at)
|
||||
VALUES (?, 'dump', ?, ?, ?, ?, ?);`,
|
||||
);
|
||||
for (const c of categories) {
|
||||
insertCategory.run(
|
||||
c.id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.position,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
);
|
||||
}
|
||||
const insertLink = db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
);
|
||||
for (const l of links) insertLink.run(l.dump_id, l.category_id);
|
||||
}
|
||||
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS playlist_categories (
|
||||
playlist_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, category_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_playlist_categories_category ON playlist_categories(category_id);`,
|
||||
);
|
||||
|
||||
renameReservedCategories(db);
|
||||
}
|
||||
@@ -69,11 +69,14 @@ CREATE TABLE playlist_dumps (
|
||||
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
updated_at TEXT,
|
||||
UNIQUE (kind, slug),
|
||||
UNIQUE (kind, name)
|
||||
);
|
||||
|
||||
CREATE TABLE dump_categories (
|
||||
@@ -84,6 +87,14 @@ CREATE TABLE dump_categories (
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_categories (
|
||||
playlist_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, category_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
dump_id TEXT NOT NULL,
|
||||
@@ -121,12 +132,12 @@ CREATE TABLE dump_backlinks (
|
||||
|
||||
CREATE INDEX idx_dumps_created ON dumps(created_at);
|
||||
CREATE INDEX idx_dumps_user_created ON dumps(user_id, created_at);
|
||||
CREATE INDEX idx_dumps_slug ON dumps(slug);
|
||||
CREATE UNIQUE INDEX idx_dumps_user_slug ON dumps(user_id, slug);
|
||||
CREATE INDEX idx_dumps_url ON dumps(url);
|
||||
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
|
||||
CREATE INDEX idx_votes_user_created ON votes(user_id, created_at);
|
||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||
CREATE INDEX idx_playlists_slug ON playlists(slug);
|
||||
CREATE UNIQUE INDEX idx_playlists_user_slug ON playlists(user_id, slug);
|
||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||
@@ -136,6 +147,7 @@ CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
|
||||
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
|
||||
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
|
||||
CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);
|
||||
CREATE INDEX idx_playlist_categories_category ON playlist_categories(category_id);
|
||||
|
||||
CREATE TABLE follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -176,7 +188,10 @@ CREATE TABLE invites (
|
||||
CREATE INDEX idx_invites_inviter ON invites(inviter_id);
|
||||
CREATE INDEX idx_users_invited_by ON users(invited_by);
|
||||
CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));
|
||||
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email));
|
||||
-- An account from another instance has no password (''), and its email may
|
||||
-- also be a local account's.
|
||||
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email))
|
||||
WHERE password_hash != '';
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -213,6 +228,51 @@ CREATE UNIQUE INDEX idx_notifications_dedup
|
||||
ON notifications(user_id, source_key)
|
||||
WHERE source_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
style TEXT,
|
||||
color_scheme TEXT,
|
||||
digest_weekly INTEGER NOT NULL DEFAULT 1,
|
||||
digest_monthly INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE digest_schedule (
|
||||
user_id TEXT NOT NULL,
|
||||
cadence TEXT NOT NULL,
|
||||
window_start TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, cadence),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE dump_slug_aliases (
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, slug)
|
||||
);
|
||||
CREATE INDEX idx_dump_slug_aliases_dump ON dump_slug_aliases(dump_id, created_at);
|
||||
|
||||
CREATE TABLE playlist_slug_aliases (
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, slug)
|
||||
);
|
||||
CREATE INDEX idx_playlist_slug_aliases_playlist ON playlist_slug_aliases(playlist_id, created_at);
|
||||
|
||||
CREATE TABLE user_aliases (
|
||||
username TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (username)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user_aliases_lower ON user_aliases(lower(username));
|
||||
CREATE INDEX idx_user_aliases_user ON user_aliases(user_id, created_at);
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -220,7 +280,70 @@ CREATE TABLE chat_messages (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
reply_to_id TEXT,
|
||||
mentions_everyone INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);
|
||||
CREATE INDEX idx_chat_messages_user ON chat_messages(user_id);
|
||||
|
||||
CREATE TABLE federation_keys (
|
||||
kid TEXT PRIMARY KEY,
|
||||
private_jwk TEXT NOT NULL,
|
||||
public_jwk TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_identities (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
picture TEXT,
|
||||
synced_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user_identities_subject
|
||||
ON user_identities(issuer, subject);
|
||||
|
||||
CREATE TABLE federation_peers (
|
||||
issuer TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE federation_logins (
|
||||
state TEXT PRIMARY KEY,
|
||||
issuer TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
invite_token TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE federation_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
client_name TEXT,
|
||||
granted_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE federation_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_federation_outbox_next ON federation_outbox(next_attempt_at);
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
|
||||
UNUSED_INVITES_RETENTION_DAYS,
|
||||
} from "../config.ts";
|
||||
import { removeScaledImages } from "../lib/ffmpeg.ts";
|
||||
import { hashPassword } from "../lib/password.ts";
|
||||
|
||||
/** Returns the time `ms` ago as an ISO string, the format dates are stored in. */
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -21,8 +24,9 @@ function isoAgo(ms: number): string {
|
||||
/**
|
||||
* 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.
|
||||
* 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> {
|
||||
// Dates are stored as ISO strings and compared as text, so the cutoffs are
|
||||
@@ -57,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
|
||||
if (orphanedAttachments.length > 0) {
|
||||
for (const { id } of orphanedAttachments) {
|
||||
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
||||
await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`);
|
||||
}
|
||||
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
|
||||
.run(attachmentCutoff);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Defines the exception that carries an HTTP error from any layer up to the
|
||||
* error middleware.
|
||||
* Lets any layer fail a request with an HTTP status, which the error middleware
|
||||
* turns into the response.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 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
|
||||
* Spots reposts and links to known dumps, by reducing 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, `GET /api/dumps?url=`
|
||||
* matches on it to warn the create form of a repost, and backlink-service
|
||||
* compares links by it. The key is never fetched or shown, so
|
||||
* it may be lossy: it forces `https`, drops `www.`, tracking parameters and
|
||||
* timestamps.
|
||||
*
|
||||
* Migration 0013 backfilled existing rows with a frozen copy of these rules.
|
||||
@@ -12,6 +13,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { siteHostOf } from "../../shared/url.ts";
|
||||
|
||||
const TRACKING_PARAMS = new Set([
|
||||
"fbclid",
|
||||
"gclid",
|
||||
@@ -51,7 +54,7 @@ const YOUTUBE_HOSTS = new Set([
|
||||
* uses it too, so both agree on what a YouTube link is.
|
||||
*/
|
||||
export function youtubeHost(u: URL): string | null {
|
||||
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||
const host = siteHostOf(u);
|
||||
return YOUTUBE_HOSTS.has(host) ? host : null;
|
||||
}
|
||||
|
||||
@@ -89,7 +92,7 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
|
||||
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||
const host = siteHostOf(u);
|
||||
if (!host) return null;
|
||||
|
||||
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
|
||||
|
||||
91
api/lib/css-color.ts
Normal file
91
api/lib/css-color.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Reads the CSS colors the server meets: a dumped page's `theme-color`, which
|
||||
* the generic link preview keeps as its tint (`normalizeCssColor`), and the
|
||||
* theme stylesheets' tokens, which the emails resolve (`theme-tokens.ts`).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */
|
||||
export interface Rgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named colors sites actually put in `theme-color`, and `transparent`,
|
||||
* which the themes write; the rest of CSS's names aren't worth carrying.
|
||||
*/
|
||||
const NAMED: Record<string, string> = {
|
||||
transparent: "#00000000",
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
red: "#ff0000",
|
||||
green: "#008000",
|
||||
blue: "#0000ff",
|
||||
yellow: "#ffff00",
|
||||
orange: "#ffa500",
|
||||
purple: "#800080",
|
||||
gray: "#808080",
|
||||
grey: "#808080",
|
||||
silver: "#c0c0c0",
|
||||
maroon: "#800000",
|
||||
navy: "#000080",
|
||||
teal: "#008080",
|
||||
olive: "#808000",
|
||||
lime: "#00ff00",
|
||||
aqua: "#00ffff",
|
||||
cyan: "#00ffff",
|
||||
fuchsia: "#ff00ff",
|
||||
magenta: "#ff00ff",
|
||||
};
|
||||
|
||||
/** Reads a channel of `rgb()`, a number or a percentage of 255. */
|
||||
function channel(text: string): number {
|
||||
const n = parseFloat(text);
|
||||
const value = text.endsWith("%") ? (n / 100) * 255 : n;
|
||||
return Math.max(0, Math.min(255, value));
|
||||
}
|
||||
|
||||
/** Reads the alpha of `rgba()`, a number or a percentage of 1. */
|
||||
function alpha(text: string): number {
|
||||
const n = parseFloat(text);
|
||||
return Math.max(0, Math.min(1, text.endsWith("%") ? n / 100 : n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the color `text` writes as a hex (3, 4, 6 or 8 digits), an `rgb()`
|
||||
* or `rgba()` of numbers or percentages, or one of the common names; undefined
|
||||
* for any other form. Channels out of range are clamped.
|
||||
*/
|
||||
export function parseCssColor(text: string): Rgba | undefined {
|
||||
const value = text.trim().toLowerCase();
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(NAMED[value] ?? value)?.[1];
|
||||
if (hex) {
|
||||
if (hex.length === 5 || hex.length === 7) return undefined;
|
||||
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
||||
const byte = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16);
|
||||
return {
|
||||
r: byte(0),
|
||||
g: byte(1),
|
||||
b: byte(2),
|
||||
a: full.length === 8 ? byte(3) / 255 : 1,
|
||||
};
|
||||
}
|
||||
const args = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
|
||||
if (!args) return undefined;
|
||||
const parts = args.split(/[\s,/]+/).filter(Boolean);
|
||||
if (parts.length !== 3 && parts.length !== 4) return undefined;
|
||||
const [r, g, b] = parts.slice(0, 3).map(channel);
|
||||
const a = parts[3] === undefined ? 1 : alpha(parts[3]);
|
||||
return [r, g, b, a].some(Number.isNaN) ? undefined : { r, g, b, a };
|
||||
}
|
||||
|
||||
/** Writes `color` as a lowercase `#rrggbb`, its alpha dropped. */
|
||||
export function hexOf({ r, g, b }: Rgba): string {
|
||||
return `#${
|
||||
[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")
|
||||
}`;
|
||||
}
|
||||
46
api/lib/dump-media.ts
Normal file
46
api/lib/dump-media.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Resolves the dump whose media a route under `/api/dumps/:dumpId/` serves:
|
||||
* its file (`routes/files.ts`), its thumbnail (`routes/thumbnails.ts`) or its
|
||||
* waveform (`routes/waveforms.ts`). Media elements and `<img>`s can't send an
|
||||
* Authorization header, so these routes take the viewer's media token from
|
||||
* the query string, and are cached by the file's version.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import { authenticateMedia } from "./auth.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import { UUID_RE } from "../../shared/validation.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
|
||||
/**
|
||||
* Returns dump `dumpId` as the viewer of `ctx`'s `?token=` may see it, and the
|
||||
* Cache-Control its media get: kept for good under a URL naming the file's
|
||||
* current version (`?v=`, as `dumpFileVersion` gives), revalidated under any
|
||||
* other, and never in a shared cache for a private dump. It also keeps the
|
||||
* token out of the Referer of whatever the response links to.
|
||||
*
|
||||
* @throws {APIException} 400 when `dumpId` isn't an id, which names files on
|
||||
* disk, and 404 when the viewer can't see the dump.
|
||||
*/
|
||||
export async function resolveMediaDump(
|
||||
ctx: Context,
|
||||
dumpId: string,
|
||||
): Promise<{ dump: Dump; cacheControl: string }> {
|
||||
if (!UUID_RE.test(dumpId)) throw new APIException(400, "Invalid dump ID");
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
return {
|
||||
dump,
|
||||
cacheControl: current
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`,
|
||||
};
|
||||
}
|
||||
512
api/lib/ffmpeg.ts
Normal file
512
api/lib/ffmpeg.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Makes what the server derives from an upload with ffmpeg: a video's still,
|
||||
* which the thumbnails route serves, an audio file's waveform, which the
|
||||
* waveform route serves, and the scaled-down copies of any image, which
|
||||
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
|
||||
* is made on its first request and kept under a name this module gives, a
|
||||
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
|
||||
* dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
|
||||
* takes what that leaves behind.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { dirname, relative } from "node:path";
|
||||
import {
|
||||
DUMPS_DIR,
|
||||
SCALED_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
|
||||
import type { Job } from "./scheduler.ts";
|
||||
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
|
||||
/**
|
||||
* Spares a process per uncached request: whether ffmpeg runs is probed once
|
||||
* per process, and kept here.
|
||||
*/
|
||||
let ffmpegProbe: Promise<boolean> | undefined;
|
||||
|
||||
function ffmpegAvailable(): Promise<boolean> {
|
||||
ffmpegProbe ??= (async () => {
|
||||
try {
|
||||
const { success } = await new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stderr: "null",
|
||||
stdout: "null",
|
||||
}).output();
|
||||
return success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return ffmpegProbe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
/**
|
||||
* 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,gif";
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an audio file as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
|
||||
*/
|
||||
const AUDIO_FORMATS =
|
||||
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
|
||||
|
||||
/**
|
||||
* Returns where the still and the waveform generated from dump `dumpId`'s
|
||||
* upload are kept.
|
||||
*/
|
||||
export function generatedPaths(dumpId: string): {
|
||||
still: string;
|
||||
waveform: string;
|
||||
} {
|
||||
const base = `${THUMBNAILS_DIR}/${dumpId}`;
|
||||
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
|
||||
* for what doesn't read it, as some mail clients don't.
|
||||
*/
|
||||
export type ScaledFormat = "webp" | "jpg" | "png";
|
||||
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
|
||||
|
||||
/**
|
||||
* Returns where the copy of the upload at `srcPath` scaled to `width` is
|
||||
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
|
||||
* `UPLOADS_DIR`.
|
||||
*/
|
||||
export function scaledImagePath(
|
||||
srcPath: string,
|
||||
width: ImageWidth,
|
||||
format: ScaledFormat = "webp",
|
||||
): string {
|
||||
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
|
||||
* One that is replaced needs no call: its copies are made again, being older
|
||||
* than it ({@link ensureGenerated}).
|
||||
*/
|
||||
export async function removeScaledImages(srcPath: string): Promise<void> {
|
||||
await Promise.all(
|
||||
IMAGE_WIDTHS.flatMap((width) =>
|
||||
SCALED_FORMATS.map((format) =>
|
||||
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes what was generated from dump `dumpId`'s upload, once dump-service
|
||||
* has replaced or deleted it. The routes make them again from a new one.
|
||||
*/
|
||||
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
|
||||
const { still, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all([
|
||||
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
|
||||
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `outPath` with `write`, which gets a path beside it to fill, renamed
|
||||
* over `outPath` once done, so a concurrent reader never gets half a file.
|
||||
*/
|
||||
async function writeAtomically(
|
||||
outPath: string,
|
||||
write: (tmpPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await Deno.mkdir(dirname(outPath), { recursive: true });
|
||||
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await write(tmpPath);
|
||||
await Deno.rename(tmpPath, outPath);
|
||||
} catch (err) {
|
||||
await Deno.remove(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `args`, writing to `outPath`.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails.
|
||||
*/
|
||||
function runFfmpeg(args: string[], outPath: string): Promise<void> {
|
||||
return writeAtomically(outPath, async (tmpPath) => {
|
||||
const { success } = await new Deno.Command("ffmpeg", {
|
||||
args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
if (!success) throw new Error("ffmpeg failed");
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
|
||||
export function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
return runFfmpeg([
|
||||
"-format_whitelist",
|
||||
VIDEO_FORMATS,
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
srcPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=320:-1",
|
||||
"-f",
|
||||
"image2",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
|
||||
* A browser applies the orientation to the upload itself; the copy carries no
|
||||
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
|
||||
* only its recent versions apply to images.
|
||||
*/
|
||||
const ORIENTATION_FILTERS: Record<number, string> = {
|
||||
2: "hflip",
|
||||
3: "hflip,vflip",
|
||||
4: "vflip",
|
||||
5: "transpose=0",
|
||||
6: "transpose=1",
|
||||
7: "transpose=3",
|
||||
8: "transpose=2",
|
||||
};
|
||||
|
||||
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
|
||||
const ENCODERS: Record<ScaledFormat, string[]> = {
|
||||
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
|
||||
jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
|
||||
png: ["-c:v", "png", "-f", "image2"],
|
||||
};
|
||||
|
||||
/**
|
||||
* What {@link scaleImage} is told of the image it scales, which
|
||||
* `scalableImage` (lib/upload.ts) reads from its first bytes.
|
||||
*/
|
||||
export interface ImageTraits {
|
||||
/** The EXIF orientation to turn the image by, 1 when it is upright. */
|
||||
orientation: number;
|
||||
/**
|
||||
* Whether its frames are an animation, as a GIF's are. A still may hold
|
||||
* more than one image too, a phone's JPEG carrying a gain map or a depth
|
||||
* map after the photo, and ffmpeg reads each as a frame.
|
||||
*/
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a copy of an image at most `width` wide, never wider than the image
|
||||
* itself, as a WebP unless `format` names another. WebP keeps a PNG's
|
||||
* transparency and a GIF's animation, at a fraction of either's bytes.
|
||||
*/
|
||||
export function scaleImage(
|
||||
srcPath: string,
|
||||
outPath: string,
|
||||
width: ImageWidth,
|
||||
{ orientation, animated }: ImageTraits,
|
||||
format: ScaledFormat = "webp",
|
||||
): Promise<void> {
|
||||
const upright = ORIENTATION_FILTERS[orientation];
|
||||
// A JPEG's colour planes halve its height, which they need even.
|
||||
const height = format === "jpg" ? -2 : -1;
|
||||
// Only an animation keeps its frames, and only as a WebP: of anything else
|
||||
// the first is the image, and a copy of them all would flash through what
|
||||
// follows it.
|
||||
const frames = animated && format === "webp" ? [] : ["-frames:v", "1"];
|
||||
return runFfmpeg([
|
||||
"-noautorotate",
|
||||
"-format_whitelist",
|
||||
IMAGE_FORMATS,
|
||||
"-i",
|
||||
srcPath,
|
||||
"-vf",
|
||||
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
|
||||
"-an",
|
||||
...frames,
|
||||
...ENCODERS[format],
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
|
||||
* does, or null when it can't be, because ffmpeg is missing or failed. The
|
||||
* image proxy scales what it fetched with it, and keeps the result itself.
|
||||
*/
|
||||
export async function scaleImageBytes(
|
||||
bytes: Uint8Array,
|
||||
width: ImageWidth,
|
||||
traits: ImageTraits,
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!await ffmpegAvailable()) return null;
|
||||
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
|
||||
const outPath = `${srcPath}.webp`;
|
||||
try {
|
||||
await Deno.mkdir(dirname(srcPath), { recursive: true });
|
||||
await Deno.writeFile(srcPath, bytes);
|
||||
await scaleImage(srcPath, outPath, width, traits);
|
||||
return await Deno.readFile(outPath);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample rate the audio is decoded at for its waveform. A bar stands for
|
||||
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
|
||||
* sixth of the samples.
|
||||
*/
|
||||
const PEAKS_SAMPLE_RATE = 8000;
|
||||
/** How many samples each running peak covers before `peaksOf` buckets them. */
|
||||
const PEAKS_WINDOW = 256;
|
||||
|
||||
/**
|
||||
* Decodes the audio of `srcPath` to its waveform's peaks. ffmpeg streams the
|
||||
* samples, mono at {@link PEAKS_SAMPLE_RATE}, and only the peak of each
|
||||
* {@link PEAKS_WINDOW} is kept, so an hour of audio holds a few hundred
|
||||
* kilobytes at most.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails or the file holds no audio.
|
||||
*/
|
||||
async function audioPeaks(srcPath: string): Promise<number[]> {
|
||||
const child = new Deno.Command("ffmpeg", {
|
||||
args: [
|
||||
"-protocol_whitelist",
|
||||
"file",
|
||||
"-format_whitelist",
|
||||
AUDIO_FORMATS,
|
||||
"-i",
|
||||
srcPath,
|
||||
"-vn",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(PEAKS_SAMPLE_RATE),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-",
|
||||
],
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).spawn();
|
||||
|
||||
const windows: number[] = [];
|
||||
let peak = 0;
|
||||
let count = 0;
|
||||
// A chunk may end halfway through a sample, whose first byte waits here.
|
||||
let pending: number | null = null;
|
||||
const take = (sample: number) => {
|
||||
peak = Math.max(peak, Math.abs(sample) / 32768);
|
||||
if (++count === PEAKS_WINDOW) {
|
||||
windows.push(peak);
|
||||
peak = 0;
|
||||
count = 0;
|
||||
}
|
||||
};
|
||||
for await (const chunk of child.stdout) {
|
||||
let i = 0;
|
||||
if (pending !== null) {
|
||||
take(((chunk[0] << 24) >> 16) | pending);
|
||||
pending = null;
|
||||
i = 1;
|
||||
}
|
||||
for (; i + 1 < chunk.length; i += 2) {
|
||||
// Little-endian, signed: the high byte is shifted into the sign bit
|
||||
// and back.
|
||||
take(((chunk[i + 1] << 24) >> 16) | chunk[i]);
|
||||
}
|
||||
if (i < chunk.length) pending = chunk[i];
|
||||
}
|
||||
if (count > 0) windows.push(peak);
|
||||
|
||||
const { success } = await child.status;
|
||||
if (!success || windows.length === 0) throw new Error("ffmpeg failed");
|
||||
return peaksOf(windows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the waveform of the audio file `srcPath` as the waveform route's
|
||||
* whole response, so the route serves it as a file, with its ETag.
|
||||
*/
|
||||
export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
|
||||
return writeAtomically(outPath, async (tmpPath) => {
|
||||
const body: APIResponse<DumpWaveform> = {
|
||||
success: true,
|
||||
data: { peaks: await audioPeaks(srcPath) },
|
||||
};
|
||||
await Deno.writeTextFile(tmpPath, JSON.stringify(body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a feed full of cards asking for the same new still at once wait on a
|
||||
* single ffmpeg run. It holds the generations under way, by output path.
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Spares an ffmpeg run per request for an image it can't read: the
|
||||
* generations that failed, by output path, with the date of the source they
|
||||
* failed on. A new upload gets a new try.
|
||||
*/
|
||||
const failed = new Map<string, number>();
|
||||
|
||||
/** Returns the date `path` was last written, or null when it doesn't exist. */
|
||||
async function writtenAt(path: string): Promise<number | null> {
|
||||
try {
|
||||
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*
|
||||
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
|
||||
* and is made again when the two differ: an upload replaced in place, as an
|
||||
* avatar or a cover is, gets new copies without anyone deleting the old.
|
||||
* Without it, the caller deletes `outPath` when its source changes.
|
||||
*/
|
||||
export async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
srcPath?: string,
|
||||
): Promise<boolean> {
|
||||
const made = await writtenAt(outPath);
|
||||
let version: number | null = null;
|
||||
if (srcPath) {
|
||||
version = await writtenAt(srcPath);
|
||||
if (version === null) return false;
|
||||
if (made === version) return true;
|
||||
if (failed.get(outPath) === version) return false;
|
||||
} else if (made !== null) {
|
||||
return true;
|
||||
}
|
||||
if (!await ffmpegAvailable()) return false;
|
||||
let pending = inFlight.get(outPath);
|
||||
if (!pending) {
|
||||
pending = generate()
|
||||
.then(() =>
|
||||
version === null
|
||||
? undefined
|
||||
: Deno.utime(outPath, new Date(), new Date(version))
|
||||
)
|
||||
.catch((err) => {
|
||||
if (version !== null) failed.set(outPath, version);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => inFlight.delete(outPath));
|
||||
inFlight.set(outPath, pending);
|
||||
}
|
||||
return await pending.then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a temporary file must be for the sweep to take it: one being
|
||||
* written, by a generation under way, is seconds old, and one a crash left
|
||||
* is never finished.
|
||||
*/
|
||||
const ABANDONED_AFTER_MS = 60 * 60_000;
|
||||
|
||||
/** Yields the path of every file under `dir`, and nothing when it is missing. */
|
||||
async function* filesUnder(dir: string): AsyncGenerator<string> {
|
||||
let entries: Deno.DirEntry[];
|
||||
try {
|
||||
entries = await Array.fromAsync(Deno.readDir(dir));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* filesUnder(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the generated files nothing serves any more, and returns how many:
|
||||
* the scaled copies of an upload that is gone, which the services' own
|
||||
* removals missed or never made, as for a user's avatar; the previews of
|
||||
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
|
||||
* the scaled copies replaced; and the temporary files a generation left
|
||||
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
|
||||
* thumbnails kept there being uploads.
|
||||
*/
|
||||
export async function sweepGeneratedMedia(): Promise<number> {
|
||||
let removed = 0;
|
||||
const remove = (path: string) =>
|
||||
Deno.remove(path).then(() => {
|
||||
removed++;
|
||||
}, () => {});
|
||||
const abandoned = async (path: string) => {
|
||||
const at = await writtenAt(path);
|
||||
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
|
||||
};
|
||||
|
||||
for await (const path of filesUnder(THUMBNAILS_DIR)) {
|
||||
if (
|
||||
/\.preview\.(jpg|png)$/.test(path) ||
|
||||
(path.endsWith(".tmp") && await abandoned(path))
|
||||
) await remove(path);
|
||||
}
|
||||
|
||||
for await (const path of filesUnder(SCALED_DIR)) {
|
||||
// A copy is named after its upload's path: see scaledImagePath.
|
||||
const copy = relative(SCALED_DIR, path).match(
|
||||
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
|
||||
);
|
||||
const unused = copy
|
||||
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
|
||||
: await abandoned(path);
|
||||
if (unused) await remove(path);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
|
||||
* clears what a new version no longer uses as soon as it is deployed, then
|
||||
* once a day. `main.ts` starts it.
|
||||
*/
|
||||
export const generatedMediaSweepJob: Job = {
|
||||
name: "media-sweep",
|
||||
everyMs: 24 * 60 * 60_000,
|
||||
firstAfterMs: 60_000,
|
||||
async run() {
|
||||
const removed = await sweepGeneratedMedia();
|
||||
if (removed > 0) {
|
||||
console.log(`[media-sweep] removed ${removed} generated files unused`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,14 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import {
|
||||
BANDCAMP_PLAYER,
|
||||
DEFAULT_STYLE,
|
||||
EMAIL_ENABLED,
|
||||
FEDERATION,
|
||||
OG_SITE_NAME,
|
||||
SITE_EMOJI,
|
||||
} from "../config.ts";
|
||||
import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
/**
|
||||
@@ -21,8 +28,9 @@ const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version and
|
||||
* Bandcamp player mode filled in.
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version,
|
||||
* Bandcamp player mode, default style, and whether email and each way of
|
||||
* federated sign-in are on filled in.
|
||||
*
|
||||
* @throws {Error} When the file can't be read.
|
||||
*/
|
||||
@@ -35,7 +43,11 @@ export async function loadIndexHtml(root: string): Promise<string> {
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
|
||||
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
|
||||
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off")
|
||||
.replaceAll("__FEDERATION_INCOMING__", FEDERATION.incoming ? "on" : "off")
|
||||
.replaceAll("__FEDERATION_OUTGOING__", FEDERATION.outgoing ? "on" : "off");
|
||||
indexHtml.set(root, { mtime, html });
|
||||
return html;
|
||||
}
|
||||
|
||||
179
api/lib/jwt.ts
179
api/lib/jwt.ts
@@ -1,15 +1,30 @@
|
||||
/**
|
||||
* 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
|
||||
* Signs and verifies the server's four kinds of token (session, media,
|
||||
* password reset and unsubscribe), 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.
|
||||
*
|
||||
* It also signs and verifies the tokens instances exchange for federated
|
||||
* sign-in (identity tokens and events), EdDSA with the instance's Ed25519 key,
|
||||
* whose public half other instances fetch: `instance-key-service.ts` keeps
|
||||
* the key, and the federation services pass it in.
|
||||
*
|
||||
* Importing this module throws when `GERBEUR_JWT_SECRET` is unset.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { jwtVerify, SignJWT } from "@panva/jose";
|
||||
import {
|
||||
createLocalJWKSet,
|
||||
exportJWK,
|
||||
generateKeyPair,
|
||||
importJWK,
|
||||
type JSONWebKeySet,
|
||||
type JWK,
|
||||
type JWTPayload,
|
||||
jwtVerify,
|
||||
SignJWT,
|
||||
} from "@panva/jose";
|
||||
|
||||
import {
|
||||
type AuthPayload,
|
||||
@@ -18,6 +33,8 @@ import {
|
||||
type PasswordResetPayload,
|
||||
} from "../model/auth.ts";
|
||||
import { JWT_SECRET } from "../config.ts";
|
||||
import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts";
|
||||
import type { AuthTokens, Role } from "../../shared/user.ts";
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error(
|
||||
@@ -60,9 +77,9 @@ export async function verifyPasswordResetToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Signs a session token carrying `payload`, valid for 7 days.
|
||||
* {@link issueTokens} issues 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">,
|
||||
@@ -76,6 +93,24 @@ export async function createJWT(
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new session token and media token for `user`, as sign-up
|
||||
* (`POST /api/users`), sign-in (`POST /api/sessions`) and a password change
|
||||
* (`PUT /api/me/password`) answer them.
|
||||
*/
|
||||
export async function issueTokens(
|
||||
user: { id: string; username: string; role: Role },
|
||||
): Promise<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}),
|
||||
mediaToken: await createMediaToken(user.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -128,3 +163,135 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the token of a digest's unsubscribe link, which turns off `cadence`
|
||||
* for `userId` without signing in. It never expires, so the link in an old
|
||||
* email still works; turning the digest back on doesn't revoke it, which is
|
||||
* harmless since all it can do is turn it off again.
|
||||
*/
|
||||
export async function createUnsubscribeToken(
|
||||
userId: string,
|
||||
cadence: DigestCadence,
|
||||
): Promise<string> {
|
||||
return await new SignJWT({ purpose: "unsubscribe", userId, cadence })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user and cadence of an unsubscribe token, or null when it is
|
||||
* invalid or another kind of token.
|
||||
*/
|
||||
export async function verifyUnsubscribeToken(
|
||||
token: string,
|
||||
): Promise<{ userId: string; cadence: DigestCadence } | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||
if (
|
||||
payload.purpose !== "unsubscribe" || typeof payload.userId !== "string" ||
|
||||
!isDigestCadence(payload.cadence)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { userId: payload.userId, cadence: payload.cadence };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The algorithm of the tokens instances exchange: Ed25519 signatures, short
|
||||
* and fast, with no parameters to get wrong.
|
||||
*/
|
||||
const INSTANCE_ALG = "EdDSA";
|
||||
|
||||
/**
|
||||
* An instance's key, ready to sign, from {@link importInstanceKey}. Its
|
||||
* `kid` names the public half in the instance's JWKS.
|
||||
*/
|
||||
export interface InstanceSigningKey {
|
||||
kid: string;
|
||||
privateKey: CryptoKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Ed25519 key pair as JWKs, with a random `kid`, for
|
||||
* `instance-key-service.ts` to store when the instance has none. The public
|
||||
* JWK carries the `kid`, `alg` and `use` a JWKS entry needs.
|
||||
*/
|
||||
export async function generateInstanceKey(): Promise<
|
||||
{ kid: string; privateJwk: JWK; publicJwk: JWK }
|
||||
> {
|
||||
const { privateKey, publicKey } = await generateKeyPair(INSTANCE_ALG, {
|
||||
crv: "Ed25519",
|
||||
extractable: true,
|
||||
});
|
||||
const kid = crypto.randomUUID();
|
||||
return {
|
||||
kid,
|
||||
privateJwk: await exportJWK(privateKey),
|
||||
publicJwk: {
|
||||
...await exportJWK(publicKey),
|
||||
kid,
|
||||
alg: INSTANCE_ALG,
|
||||
use: "sig",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the stored private JWK `privateJwk` as a key to sign with. */
|
||||
export async function importInstanceKey(
|
||||
kid: string,
|
||||
privateJwk: JWK,
|
||||
): Promise<InstanceSigningKey> {
|
||||
const privateKey = await importJWK(privateJwk, INSTANCE_ALG);
|
||||
if (!(privateKey instanceof CryptoKey)) {
|
||||
throw new Error("The instance key is not an asymmetric key");
|
||||
}
|
||||
return { kid, privateKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs `payload` with the instance's key, from `issuer` (this instance) to
|
||||
* `audience` (the instance it is for), valid for `expiresIn`, with a random
|
||||
* `jti`. The identity tokens of `POST /api/oauth/tokens` and the events of
|
||||
* the outbox are signed with it.
|
||||
*/
|
||||
export async function signInstanceToken(
|
||||
payload: JWTPayload,
|
||||
key: InstanceSigningKey,
|
||||
claims: { issuer: string; audience: string; subject: string },
|
||||
expiresIn: string,
|
||||
): Promise<string> {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: INSTANCE_ALG, kid: key.kid })
|
||||
.setIssuer(claims.issuer)
|
||||
.setAudience(claims.audience)
|
||||
.setSubject(claims.subject)
|
||||
.setIssuedAt()
|
||||
.setJti(crypto.randomUUID())
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(key.privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload of `token` once its signature checks against `jwks`,
|
||||
* the keys of `issuer`, and it is addressed to `audience` and unexpired.
|
||||
*
|
||||
* @throws {Error} jose's error when it doesn't verify; its `code` is
|
||||
* `ERR_JWKS_NO_MATCHING_KEY` when no key of `jwks` has its `kid`, which
|
||||
* tells the caller to refetch the keys, the issuer having rotated them.
|
||||
*/
|
||||
export async function verifyInstanceToken(
|
||||
token: string,
|
||||
jwks: JSONWebKeySet,
|
||||
claims: { issuer: string; audience: string },
|
||||
): Promise<JWTPayload> {
|
||||
const { payload } = await jwtVerify(token, createLocalJWKSet(jwks), {
|
||||
issuer: claims.issuer,
|
||||
audience: claims.audience,
|
||||
algorithms: [INSTANCE_ALG],
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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`.
|
||||
* Pages every list route the same way. It reads the page a list route's query
|
||||
* string asks for, and cuts the rows a service fetched into that page, which
|
||||
* the routes send as `PaginatedData`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* filters its listings with it, the WebSocket service filters each client's
|
||||
* `playlist_dumps_updated` message the same way, and the queries that list
|
||||
* playlists count their dumps by the same rule.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -34,3 +35,53 @@ export function isMemberVisibleTo(
|
||||
return !member.isPrivate ||
|
||||
(member.userId === viewerId && viewerId === playlist.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
|
||||
* that tell who may see it, and when it was added. The playlist service reads
|
||||
* them for its broadcasts.
|
||||
*/
|
||||
export interface DatedPlaylistMember extends PlaylistMember {
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */
|
||||
const visibleDumps = (figure: string) =>
|
||||
`(SELECT ${figure} FROM playlist_dumps pd
|
||||
INNER JOIN dumps d ON d.id = pd.dump_id
|
||||
WHERE pd.playlist_id = p.id
|
||||
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
|
||||
* how many of the playlist's dumps the viewer may see, by
|
||||
* {@link isMemberVisibleTo}'s rule, and when the latest of them was added,
|
||||
* null without any. Both read the viewer's id, or `""` for a signed-out one,
|
||||
* as `?1`: it is the query's first parameter, and each `?` after it takes the
|
||||
* next.
|
||||
*/
|
||||
export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
|
||||
${visibleDumps("MAX(pd.added_at)")} as last_dump_at`;
|
||||
|
||||
/**
|
||||
* Returns what `playlist` says of its dumps to the viewer, `members` being
|
||||
* all of them: the `dumpCount` and `lastDumpAt` that
|
||||
* {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts
|
||||
* them on the playlist each socket is sent.
|
||||
*/
|
||||
export function dumpFiguresFor(
|
||||
members: DatedPlaylistMember[],
|
||||
playlist: Pick<Playlist, "userId">,
|
||||
viewerId: string | null | undefined,
|
||||
): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
|
||||
const visible = members.filter((m) =>
|
||||
isMemberVisibleTo(m, playlist, viewerId)
|
||||
);
|
||||
return {
|
||||
dumpCount: visible.length,
|
||||
lastDumpAt: visible.length
|
||||
? new Date(Math.max(...visible.map((m) => m.addedAt.getTime())))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Job } from "./scheduler.ts";
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
at: number;
|
||||
@@ -42,11 +44,18 @@ export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (bucket.at < cutoff) buckets.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
/**
|
||||
* Drops the buckets untouched for an hour, every minute. Every limit in use
|
||||
* refills within the hour, so such a bucket is full and dropping it changes
|
||||
* nothing. `main.ts` starts it.
|
||||
*/
|
||||
export const rateLimitSweepJob: Job = {
|
||||
name: "rate-limit",
|
||||
everyMs: 60_000,
|
||||
run() {
|
||||
const cutoff = Date.now() - 60 * 60_000;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.at < cutoff) buckets.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,47 +1,78 @@
|
||||
/**
|
||||
* 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.
|
||||
* Checks which slugs a dump category may take. A dump category's feed lives at
|
||||
* the URL root (`/music`), so its slug must not shadow another top-level page.
|
||||
* The category service runs this check when a dump category is created or
|
||||
* renamed, and the migrations that reserve more words rename the categories
|
||||
* that had one with {@link renameReservedCategories}. A playlist category's
|
||||
* page is `/playlists/<slug>`, under a reserved word, so it reserves nothing.
|
||||
*
|
||||
* Dump and playlist slugs reserve nothing: they live under their owner's
|
||||
* `dumps/` and `playlists/`, where nothing else is named by a user. A user
|
||||
* can't clash with a category either, since `@` is never in a slug.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { FEED_TABS } from "../../shared/paths.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists the top-level path segments a category slug may not take: the feed
|
||||
* tabs (`/hot`), the SPA's own pages (`src/App.tsx`), and `api`, `ws`,
|
||||
* `assets` and `emojibase`, which the server answers before the SPA. Keep it
|
||||
* in step with the route table and the build's top-level folders.
|
||||
*/
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
"~",
|
||||
"dumps",
|
||||
"users",
|
||||
"playlists",
|
||||
...FEED_TABS,
|
||||
"search",
|
||||
"login",
|
||||
"register",
|
||||
"notifications",
|
||||
"reset-password",
|
||||
"unsubscribe",
|
||||
"settings",
|
||||
"manage",
|
||||
"community",
|
||||
"playlists",
|
||||
"authorize",
|
||||
"federation",
|
||||
"api",
|
||||
"ws",
|
||||
"assets",
|
||||
"emojibase",
|
||||
];
|
||||
|
||||
/**
|
||||
* 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` matches {@link CATEGORY_SLUG_RE}. */
|
||||
export function isValidSlugFormat(slug: string): boolean {
|
||||
return CATEGORY_SLUG_RE.test(slug);
|
||||
}
|
||||
|
||||
/** Checks whether `slug` is one of the {@link RESERVED_SLUGS}. */
|
||||
export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.includes(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames each category whose slug is now reserved, such as `hot`, to the
|
||||
* first of `<slug>-2`, `<slug>-3`… that is free, and logs it. Migration 0021
|
||||
* runs it for v4's words, 0022 for `community`, 0024 for `authorize` and
|
||||
* `federation`, and 0025 for `playlists`.
|
||||
*
|
||||
* It reads every category: until 0025 gave categories a `kind`, each was a
|
||||
* dump category, and 0025 makes no other. A later migration that reserves
|
||||
* more words must leave the playlist categories alone, and limit it to
|
||||
* `kind = 'dump'`, which the earlier ones can't, having no such column.
|
||||
*/
|
||||
export function renameReservedCategories(db: DatabaseSync): void {
|
||||
const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as {
|
||||
id: string;
|
||||
slug: string;
|
||||
}[];
|
||||
const taken = new Set(rows.map((r) => r.slug));
|
||||
for (const { id, slug } of rows) {
|
||||
if (!isReservedSlug(slug)) continue;
|
||||
let n = 2;
|
||||
while (taken.has(`${slug}-${n}`) || isReservedSlug(`${slug}-${n}`)) n++;
|
||||
const renamed = `${slug}-${n}`;
|
||||
taken.add(renamed);
|
||||
db.prepare(`UPDATE categories SET slug = ? WHERE id = ?;`).run(renamed, id);
|
||||
console.log(
|
||||
`[migrate] category "${slug}" is now "${renamed}": its slug is reserved`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
46
api/lib/scheduler.ts
Normal file
46
api/lib/scheduler.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Runs the server's recurring tasks. Each module that needs one exports a
|
||||
* {@link Job}, and `main.ts` passes them all to {@link startJobs} when it runs
|
||||
* directly, so nothing recurs in the tests, which call a job's `run` when
|
||||
* they need it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A recurring task, exported by its module for `main.ts` to start. */
|
||||
export interface Job {
|
||||
/** Names it in the log, as `[digest]`. */
|
||||
name: string;
|
||||
/** How long from one run's start to the next. */
|
||||
everyMs: number;
|
||||
/** How long after the start the first run waits, `everyMs` by default. */
|
||||
firstAfterMs?: number;
|
||||
/** Logs what it did itself, if anything; its errors are logged here. */
|
||||
run(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts running each of `jobs` on its own timer. A run still going when the
|
||||
* next is due makes that one skip, and a run that throws or rejects is logged
|
||||
* and the next one runs anyway.
|
||||
*/
|
||||
export function startJobs(jobs: readonly Job[]): void {
|
||||
for (const job of jobs) {
|
||||
let running = false;
|
||||
const tick = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
await job.run();
|
||||
} catch (err) {
|
||||
console.error(`[${job.name}] run failed:`, err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
tick();
|
||||
setInterval(tick, job.everyMs);
|
||||
}, job.firstAfterMs ?? job.everyMs);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
* from the database, remote/fetch.ts accepts them as dump links even on a
|
||||
* private host such as localhost, and migration 0021 rewrote the v3 ones in
|
||||
* stored text.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
|
||||
import { parsePagePath } from "../../shared/paths.ts";
|
||||
|
||||
const SELF_HOSTS = new Set(
|
||||
[PUBLIC_URL, FRONTEND_URL].flatMap((u) => {
|
||||
@@ -18,14 +20,22 @@ const SELF_HOSTS = new Set(
|
||||
}),
|
||||
);
|
||||
|
||||
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
|
||||
/**
|
||||
* Checks whether `host` (with its port, as `URL.host` gives it) is where this
|
||||
* site is served, at its public URL or its frontend's.
|
||||
*/
|
||||
export function isSelfHost(host: string): boolean {
|
||||
return SELF_HOSTS.has(host.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `url` links to one of this site's own dump, user or playlist
|
||||
* pages. Matched on the host as well as the path: `/users/bob` on some other
|
||||
* site is someone else's page, not ours.
|
||||
* pages, by `parsePagePath`; an editor doesn't count. Matched on the
|
||||
* host as well as the path: `/@bob` on some other site is someone else's
|
||||
* page, not ours.
|
||||
*/
|
||||
export function isSelfUrl(url: URL): boolean {
|
||||
return SELF_HOSTS.has(url.host.toLowerCase()) &&
|
||||
SELF_PATH_RE.test(url.pathname);
|
||||
if (!isSelfHost(url.host)) return false;
|
||||
const page = parsePagePath(url.pathname);
|
||||
return !!page && !(page.kind !== "user" && page.edit);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,86 @@
|
||||
/**
|
||||
* 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.
|
||||
* Picks and checks the slugs of dumps and playlists, which name them in their
|
||||
* page URLs under their owner: `/@bob/dumps/<slug>`, `/@bob/playlists/<slug>`.
|
||||
* Each kind has its own slug space per owner, shared by the items' current
|
||||
* slugs and their aliases (the slugs they had before). `services/slug-service.ts`
|
||||
* changes and resolves slugs with these, and migration 0021 gave every item
|
||||
* one; both pass the database, since this module doesn't open it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import { isValidSlug, SLUG_MAX } from "../../shared/slug.ts";
|
||||
|
||||
/** A kind of item that has a slug under its owner. */
|
||||
export type SlugKind = "dump" | "playlist";
|
||||
|
||||
/** Where each {@link SlugKind}'s slugs live. */
|
||||
export const SLUG_TABLES: Record<
|
||||
SlugKind,
|
||||
{ items: string; aliases: string; itemId: string }
|
||||
> = {
|
||||
dump: { items: "dumps", aliases: "dump_slug_aliases", itemId: "dump_id" },
|
||||
playlist: {
|
||||
items: "playlists",
|
||||
aliases: "playlist_slug_aliases",
|
||||
itemId: "playlist_id",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Returns the id of the item whose current slug or alias is `slug` in
|
||||
* `userId`'s space for `kind`, or undefined when it is free.
|
||||
*/
|
||||
export function slugify(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
// 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";
|
||||
export function slugHolder(
|
||||
db: DatabaseSync,
|
||||
kind: SlugKind,
|
||||
userId: string,
|
||||
slug: string,
|
||||
): string | undefined {
|
||||
const { items, aliases, itemId } = SLUG_TABLES[kind];
|
||||
const row = db.prepare(
|
||||
`SELECT id FROM ${items} WHERE user_id = ? AND slug = ?
|
||||
UNION ALL
|
||||
SELECT ${itemId} FROM ${aliases} WHERE user_id = ? AND slug = ?
|
||||
LIMIT 1;`,
|
||||
).get(userId, slug, userId, slug) as { id: string } | undefined;
|
||||
return row?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns `base`, or `base-2`, `base-3`… cut to fit, whichever is free in
|
||||
* `userId`'s space for `kind`. `base` must be a valid slug, as `slugify`
|
||||
* makes. The services take it on creating an item, the migration on
|
||||
* re-slugging one, inside the transaction that writes it.
|
||||
*/
|
||||
export function makeSlug(title: string, id: string): string {
|
||||
return `${slugify(title)}-${id.substring(0, 8)}`;
|
||||
export function uniqueSlug(
|
||||
db: DatabaseSync,
|
||||
kind: SlugKind,
|
||||
userId: string,
|
||||
base: string,
|
||||
): string {
|
||||
for (let n = 1;; n++) {
|
||||
const suffix = n === 1 ? "" : `-${n}`;
|
||||
const candidate = `${
|
||||
base.substring(0, SLUG_MAX - suffix.length).replace(/-+$/, "")
|
||||
}${suffix}`;
|
||||
if (!slugHolder(db, kind, userId, candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is a valid slug, lowercase words of letters and
|
||||
* digits joined by hyphens, at most `max` long. slug-service checks a dump's
|
||||
* or a playlist's with it, and category-service a category's, with that
|
||||
* kind's own limit.
|
||||
*/
|
||||
export function validateSlug(slug: string, max: number = SLUG_MAX): void {
|
||||
if (!isValidSlug(slug, max)) {
|
||||
throw new APIException(
|
||||
400,
|
||||
`Slug must be lowercase letters, numbers and hyphens, at most ${max} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
export function routeStaticFilesFrom(
|
||||
staticPaths: string[],
|
||||
): (context: Context<Record<string, object>>, next: Next) => Promise<void> {
|
||||
// The last middleware: it always answers, so it never calls `next`.
|
||||
// It is the last middleware and always answers, so it never calls `next`.
|
||||
return async (context: Context<Record<string, object>>, _next: Next) => {
|
||||
const pathname = context.request.url.pathname;
|
||||
|
||||
|
||||
60
api/lib/ttl-cache.ts
Normal file
60
api/lib/ttl-cache.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Keeps what the server fetched from another site for a while, by key, so a
|
||||
* page or a feed asking again doesn't refetch it: Bandcamp tracklists, YouTube
|
||||
* playlists, and link previews. Each holder sets how long an entry lives and
|
||||
* how many are kept; past that count, the oldest written goes first.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* A map whose entries expire `ttlMs` after they are written, holding
|
||||
* `maxEntries` at most.
|
||||
*
|
||||
* @example
|
||||
* const playlists = new TtlCache<YouTubePlaylist>(6 * 3_600_000, 200);
|
||||
* const hit = playlists.get(listId) ?? playlists.set(listId, await fetch());
|
||||
*/
|
||||
export class TtlCache<V> {
|
||||
#entries = new Map<string, { value: V; at: number }>();
|
||||
|
||||
constructor(readonly ttlMs: number, readonly maxEntries: number) {}
|
||||
|
||||
/** Returns the value under `key` unless it is missing or expired. */
|
||||
get(key: string): V | undefined {
|
||||
const hit = this.#entries.get(key);
|
||||
if (!hit) return undefined;
|
||||
if (Date.now() - hit.at >= this.ttlMs) {
|
||||
this.#entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
/** Stores `value` under `key` from now on, and returns it. */
|
||||
set(key: string, value: V): V {
|
||||
this.#entries.delete(key);
|
||||
// A Map keeps insertion order, so the first key is the oldest write.
|
||||
if (this.#entries.size >= this.maxEntries) {
|
||||
const oldest = this.#entries.keys().next().value;
|
||||
if (oldest !== undefined) this.#entries.delete(oldest);
|
||||
}
|
||||
this.#entries.set(key, { value, at: Date.now() });
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `key`, or only when `value` is still what it holds, so a late
|
||||
* failure can't remove the entry that replaced its own.
|
||||
*/
|
||||
delete(key: string, value?: V): void {
|
||||
if (value === undefined || this.#entries.get(key)?.value === value) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Empties the cache, for the tests' reset between cases. */
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
* Checks the images users upload and serves them back, scaled down to the
|
||||
* width a request names. The avatar, playlist cover, dump thumbnail and
|
||||
* attachment routes call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,6 +11,14 @@ import { APIException } from "./api-exception.ts";
|
||||
import { serveFile } from "./serve-file.ts";
|
||||
import { dirname } from "node:path";
|
||||
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
|
||||
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
|
||||
import {
|
||||
ensureGenerated,
|
||||
type ImageTraits,
|
||||
type ScaledFormat,
|
||||
scaledImagePath,
|
||||
scaleImage,
|
||||
} from "./ffmpeg.ts";
|
||||
|
||||
/**
|
||||
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
||||
@@ -62,10 +71,176 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its
|
||||
* bytes, and 1, upright, when it states none. A phone stores a photo as its
|
||||
* sensor saw it and says here how to turn it.
|
||||
*/
|
||||
function jpegOrientation(data: Uint8Array): number {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
// The segments follow the two-byte SOI, each a marker and its length.
|
||||
let at = 2;
|
||||
while (at + 4 <= data.length && data[at] === 0xFF) {
|
||||
const marker = data[at + 1];
|
||||
const length = view.getUint16(at + 2);
|
||||
// APP1 holding "Exif\0\0", then a TIFF header and its first directory.
|
||||
if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) {
|
||||
const tiff = at + 10;
|
||||
if (tiff + 8 > data.length) return 1;
|
||||
const little = view.getUint16(tiff) === 0x4949;
|
||||
const ifd = tiff + view.getUint32(tiff + 4, little);
|
||||
if (ifd + 2 > data.length) return 1;
|
||||
const entries = view.getUint16(ifd, little);
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const entry = ifd + 2 + i * 12;
|
||||
if (entry + 12 > data.length) return 1;
|
||||
if (view.getUint16(entry, little) === 0x0112) {
|
||||
const value = view.getUint16(entry + 8, little);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// The image data starts: no EXIF came before it.
|
||||
if (marker === 0xDA) return 1;
|
||||
at += 2 + length;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a file {@link scalableImage} needs: the EXIF segment, which
|
||||
* comes first in a JPEG, is 64 KB at most.
|
||||
*/
|
||||
const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
|
||||
|
||||
/** Returned by {@link scalableImage}, for `scaleImage`. */
|
||||
export interface ScalableImage extends ImageTraits {
|
||||
/**
|
||||
* The format of its copy for a reader without WebP: a JPEG, or a PNG for
|
||||
* an image that may be transparent, which a JPEG would fill with black.
|
||||
*/
|
||||
compat: "jpg" | "png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are
|
||||
* `head`, or null when it is to be served as it is: an animated WebP, which
|
||||
* ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP.
|
||||
*/
|
||||
export function scalableImage(head: Uint8Array): ScalableImage | null {
|
||||
const mime = detectImageMime(head);
|
||||
// A file dump may be a BMP ("BM"), which no upload route here accepts.
|
||||
if (!mime) {
|
||||
return head[0] === 0x42 && head[1] === 0x4D
|
||||
? { orientation: 1, animated: false, compat: "jpg" }
|
||||
: null;
|
||||
}
|
||||
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
|
||||
const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
|
||||
(head[20] & 0x02) !== 0;
|
||||
if (animatedWebp) return null;
|
||||
return mime === "image/jpeg"
|
||||
? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
|
||||
: { orientation: 1, animated: mime === "image/gif", compat: "png" };
|
||||
}
|
||||
|
||||
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
|
||||
async function readHead(path: string): Promise<Uint8Array | null> {
|
||||
let file: Deno.FsFile;
|
||||
try {
|
||||
file = await Deno.open(path, { read: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const head = new Uint8Array(IMAGE_HEAD_BYTES);
|
||||
let filled = 0;
|
||||
while (filled < head.length) {
|
||||
const n = await file.read(head.subarray(filled));
|
||||
if (n === null) break;
|
||||
filled += n;
|
||||
}
|
||||
return head.subarray(0, filled);
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The type each format of a scaled copy is served as. */
|
||||
const SCALED_MIMES: Record<ScaledFormat, string> = {
|
||||
webp: "image/webp",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the copy of the image at `path` scaled to `width`, made now unless
|
||||
* it was already, as its path and type, or null when there is none to serve:
|
||||
* the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or
|
||||
* failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader
|
||||
* without WebP takes.
|
||||
*/
|
||||
async function scaledCopy(
|
||||
path: string,
|
||||
width: ImageWidth,
|
||||
compat: boolean,
|
||||
): Promise<{ path: string; mime: string } | null> {
|
||||
// The other format depends on the image, so it is read before the copy is
|
||||
// looked for; a WebP's path is known without.
|
||||
let how = compat ? await describe(path) : undefined;
|
||||
if (how === null) return null;
|
||||
const format: ScaledFormat = how ? how.compat : "webp";
|
||||
const scaledPath = scaledImagePath(path, width, format);
|
||||
const made = await ensureGenerated(scaledPath, async () => {
|
||||
how ??= await describe(path);
|
||||
if (!how) throw new Error("Not an image that can be scaled");
|
||||
await scaleImage(path, scaledPath, width, how, format);
|
||||
}, path);
|
||||
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
|
||||
}
|
||||
|
||||
/** Returns how to scale the image at `path`: see {@link scalableImage}. */
|
||||
async function describe(path: string): Promise<ScalableImage | null> {
|
||||
const head = await readHead(path);
|
||||
return head && scalableImage(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy
|
||||
* at most as wide as the request's `?w=` names (`shared/image.ts`), or as
|
||||
* `defaultWidth` without one, and the image as it is when the request names
|
||||
* no width or the copy can't be made. With `?f=compat`, the copy is a JPEG,
|
||||
* or a PNG for an image that may be transparent, which the digest's emails
|
||||
* ask for, since not every mail client reads WebP. Every route that answers
|
||||
* one of the site's own images ends here, so each takes both. It resolves
|
||||
* false, having sent nothing, when the file is missing.
|
||||
*/
|
||||
export async function serveImage(
|
||||
ctx: Context,
|
||||
path: string,
|
||||
mime: string,
|
||||
cacheControl: string,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ??
|
||||
defaultWidth;
|
||||
const compat = ctx.request.url.searchParams.get("f") === "compat";
|
||||
const scaled = width ? await scaledCopy(path, width, compat) : null;
|
||||
if (
|
||||
scaled &&
|
||||
await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl })
|
||||
) return true;
|
||||
return await serveFile(ctx, path, { mime, cacheControl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded image inline as `mime`, or scaled down as
|
||||
* {@link serveImage} does, 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,
|
||||
@@ -73,16 +248,18 @@ export async function serveUploadedFile(
|
||||
mime: string,
|
||||
cacheMaxAge = 3600,
|
||||
isPrivate = false,
|
||||
defaultWidth?: ImageWidth,
|
||||
): 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");
|
||||
// A frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
const served = await serveImage(
|
||||
ctx,
|
||||
filePath,
|
||||
mime,
|
||||
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
});
|
||||
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
defaultWidth,
|
||||
);
|
||||
if (!served) ctx.response.status = 404;
|
||||
return served;
|
||||
}
|
||||
|
||||
626
api/mail/digest.ts
Normal file
626
api/mail/digest.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Renders the weekly digest and the monthly recap, as the drafts approved
|
||||
* them: the weekly one has four sections (what happened to the reader, what
|
||||
* the people and playlists they follow posted, the period's top dumps, and
|
||||
* the community), the monthly one only the last two. `digest-service.ts`
|
||||
* gathers a {@link DigestContent} and sends what this returns, drawn in the
|
||||
* reader's own style and scheme.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
|
||||
import type { DigestCadence } from "../../shared/settings.ts";
|
||||
import { i18n } from "./i18n.ts";
|
||||
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
|
||||
import {
|
||||
EmailKit,
|
||||
type EmailLook,
|
||||
escMd,
|
||||
mdLink,
|
||||
type RenderedEmail,
|
||||
} from "./layout.ts";
|
||||
|
||||
/** The kinds of activity a weekly digest counts. */
|
||||
export type ActivityKind =
|
||||
| "upvotes"
|
||||
| "followers"
|
||||
| "comments"
|
||||
| "mentions"
|
||||
| "likes"
|
||||
| "playlistFollows";
|
||||
|
||||
/** What a highlight of the weekly digest says happened. */
|
||||
export type HighlightKind =
|
||||
| "mentionComment"
|
||||
| "mentionDump"
|
||||
| "mentionPlaylist"
|
||||
| "mentionChat"
|
||||
| "mentionEveryone"
|
||||
| "comment"
|
||||
| "playlistFollow"
|
||||
| "userFollow"
|
||||
| "commentLike";
|
||||
|
||||
/** A dump in a digest's lists, with its links made absolute. */
|
||||
export interface DigestDump {
|
||||
title: string;
|
||||
url: string;
|
||||
poster: string;
|
||||
/** The link's host, or null for an upload. */
|
||||
domain: string | null;
|
||||
/** An absolute image URL, or null to show no image. */
|
||||
thumbnail: string | null;
|
||||
votes: number;
|
||||
comments: number;
|
||||
/** Posted by the reader, which the lists mark. */
|
||||
mine: boolean;
|
||||
/** The followed playlist it was added to, when that's why it's listed. */
|
||||
addedTo?: string;
|
||||
}
|
||||
|
||||
/** One line of the weekly digest's "For you", made from a notification. */
|
||||
export interface DigestHighlight {
|
||||
kind: HighlightKind;
|
||||
/** Who did it. */
|
||||
who: string;
|
||||
/**
|
||||
* What they did it on, absent for a follow of the reader or the chat. Its
|
||||
* `url` is null once it is deleted, and the title then shows unlinked.
|
||||
*/
|
||||
target?: { title: string; url: string | null };
|
||||
/** The comment or message, flattened and shortened. */
|
||||
quote?: string;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to {@link renderDigestEmail} by `digest-service.ts`. Each section is
|
||||
* null when it has nothing to show, and is left out; the service sends
|
||||
* nothing when all of them are.
|
||||
*/
|
||||
export interface DigestContent {
|
||||
cadence: DigestCadence;
|
||||
/** The period covered, from `from` to `to`. */
|
||||
from: Date;
|
||||
to: Date;
|
||||
reader: string;
|
||||
/** Weekly only. */
|
||||
activity: {
|
||||
counts: Partial<Record<ActivityKind, number>>;
|
||||
highlights: DigestHighlight[];
|
||||
/** Every notification of the period, which the link to them counts. */
|
||||
total: number;
|
||||
} | null;
|
||||
/** Weekly only. */
|
||||
network: { dumps: DigestDump[]; more: number } | null;
|
||||
top: DigestDump[];
|
||||
community: {
|
||||
members: { username: string; invitedBy: string | null }[];
|
||||
membersMore: number;
|
||||
playlists: { title: string; url: string; owner: string; dumps: number }[];
|
||||
playlistsMore: number;
|
||||
chatMessages: number;
|
||||
totals: { dumps: number; upvotes: number; comments: number };
|
||||
} | null;
|
||||
links: {
|
||||
notifications: string;
|
||||
followed: string;
|
||||
chat: string;
|
||||
/** The community page's view of the cadence's period. */
|
||||
community: string;
|
||||
settings: string;
|
||||
/** The one-click page that turns this cadence off. */
|
||||
unsubscribe: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVITY_ORDER: ActivityKind[] = [
|
||||
"upvotes",
|
||||
"followers",
|
||||
"comments",
|
||||
"mentions",
|
||||
"likes",
|
||||
"playlistFollows",
|
||||
];
|
||||
|
||||
/** Returns the label of `n` of a kind of activity, as in "12 upvotes". */
|
||||
function countLabel(kind: ActivityKind, n: number): string {
|
||||
switch (kind) {
|
||||
case "upvotes":
|
||||
return i18n._({ ...DIGEST_MESSAGES.countUpvotes, values: { n } });
|
||||
case "followers":
|
||||
return i18n._({
|
||||
id: "email.digest.count.followers",
|
||||
message: "{n, plural, one {new follower} other {new followers}}",
|
||||
values: { n },
|
||||
});
|
||||
case "comments":
|
||||
return i18n._({ ...DIGEST_MESSAGES.countComments, values: { n } });
|
||||
case "mentions":
|
||||
return i18n._({
|
||||
id: "email.digest.count.mentions",
|
||||
message: "{n, plural, one {mention} other {mentions}}",
|
||||
values: { n },
|
||||
});
|
||||
case "likes":
|
||||
return i18n._({
|
||||
id: "email.digest.count.likes",
|
||||
message: "{n, plural, one {comment like} other {comment likes}}",
|
||||
values: { n },
|
||||
});
|
||||
case "playlistFollows":
|
||||
return i18n._({
|
||||
id: "email.digest.count.playlistFollows",
|
||||
message: "{n, plural, one {playlist follow} other {playlist follows}}",
|
||||
values: { n },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sentence of a highlight, `who` and `target` already formatted
|
||||
* for the part it goes in (HTML or Markdown).
|
||||
*/
|
||||
function highlightSentence(
|
||||
kind: HighlightKind,
|
||||
who: string,
|
||||
target: string,
|
||||
): string {
|
||||
const values = { who, target };
|
||||
switch (kind) {
|
||||
case "mentionComment":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.mentionComment",
|
||||
message: "{who} mentioned you in a comment on {target}",
|
||||
values,
|
||||
});
|
||||
case "mentionDump":
|
||||
case "mentionChat":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.mentionIn",
|
||||
message: "{who} mentioned you in {target}",
|
||||
values,
|
||||
});
|
||||
case "mentionPlaylist":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.mentionPlaylist",
|
||||
message: "{who} mentioned you in the playlist {target}",
|
||||
values,
|
||||
});
|
||||
case "mentionEveryone":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.mentionEveryone",
|
||||
message: "{who} mentioned everyone in {target}",
|
||||
values,
|
||||
});
|
||||
case "comment":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.comment",
|
||||
message: "{who} commented on your dump {target}",
|
||||
values,
|
||||
});
|
||||
case "playlistFollow":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.playlistFollow",
|
||||
message: "{who} followed your playlist {target}",
|
||||
values,
|
||||
});
|
||||
case "userFollow":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.userFollow",
|
||||
message: "{who} started following you",
|
||||
values,
|
||||
});
|
||||
case "commentLike":
|
||||
return i18n._({
|
||||
id: "email.digest.hl.commentLike",
|
||||
message: "{who} liked your comment on {target}",
|
||||
values,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byLine = (username: string) =>
|
||||
i18n._({
|
||||
id: "email.digest.by",
|
||||
message: "by @{username}",
|
||||
values: { username },
|
||||
});
|
||||
const addedToLine = (playlist: string) =>
|
||||
i18n._({
|
||||
id: "email.digest.addedTo",
|
||||
message: "added to {playlist}",
|
||||
values: { playlist },
|
||||
});
|
||||
const uploadLabel = () =>
|
||||
i18n._({ id: "email.digest.upload", message: "upload" });
|
||||
const invitedLine = (inviter: string, reader: string) =>
|
||||
inviter === reader
|
||||
? i18n._(DIGEST_MESSAGES.invitedByYou)
|
||||
: i18n._({ ...DIGEST_MESSAGES.invitedBy, values: { username: inviter } });
|
||||
const andMoreLine = (n: number) =>
|
||||
i18n._({
|
||||
id: "email.digest.andMore",
|
||||
message: "and {n} more",
|
||||
values: { n },
|
||||
});
|
||||
const playlistMeta = (owner: string, n: number) =>
|
||||
i18n._({
|
||||
id: "email.digest.playlistMeta",
|
||||
message: "by @{username} · {n, plural, one {# dump} other {# dumps}}",
|
||||
values: { username: owner, n },
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the digest `content` describes, written as Markdown and drawn in
|
||||
* `look`, with its subject and hidden preheader.
|
||||
*/
|
||||
export function renderDigestEmail(
|
||||
content: DigestContent,
|
||||
look: EmailLook,
|
||||
): RenderedEmail {
|
||||
const monthly = content.cadence === "monthly";
|
||||
const nf = new Intl.NumberFormat(EMAIL_LOCALE);
|
||||
const range = (year: boolean) =>
|
||||
new Intl.DateTimeFormat(
|
||||
EMAIL_LOCALE,
|
||||
year
|
||||
? { month: "short", day: "numeric", year: "numeric" }
|
||||
: { month: "short", day: "numeric" },
|
||||
).formatRange(content.from, content.to);
|
||||
const day = (d: Date) =>
|
||||
new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d);
|
||||
const figure = (n: number, label: string) =>
|
||||
`**${nf.format(n)}** ${escMd(label)}`;
|
||||
const yours = i18n._({ id: "email.digest.yours", message: "yours" });
|
||||
/** A dump as a list item: its thumbnail, title, pill and detail. */
|
||||
const dumpItem = (d: DigestDump, bullet: string, image = true) =>
|
||||
`${bullet} ${image && d.thumbnail ? ` ` : ""}${
|
||||
mdLink(d.title, d.url)
|
||||
}${d.mine ? ` *${escMd(yours)}*` : ""} · ${
|
||||
escMd(
|
||||
`${d.addedTo ? addedToLine(d.addedTo) : byLine(d.poster)}${
|
||||
d.addedTo ? "" : ` · ${d.domain ?? uploadLabel()}`
|
||||
} · ${nf.format(d.votes)} ${countLabel("upvotes", d.votes)} · ${
|
||||
nf.format(d.comments)
|
||||
} ${countLabel("comments", d.comments)}`,
|
||||
)
|
||||
}`;
|
||||
|
||||
const md: string[] = [
|
||||
`*${
|
||||
escMd(
|
||||
`${
|
||||
monthly
|
||||
? i18n._(DIGEST_MESSAGES.monthly)
|
||||
: i18n._(DIGEST_MESSAGES.weekly)
|
||||
} · ${range(true)}`,
|
||||
)
|
||||
}*`,
|
||||
`# ${
|
||||
escMd(
|
||||
monthly
|
||||
? i18n._({
|
||||
id: "email.digest.monthly.title",
|
||||
message: "The month on {site}",
|
||||
values: { site: OG_SITE_NAME },
|
||||
})
|
||||
: i18n._({
|
||||
id: "email.digest.weekly.title",
|
||||
message: "Your week, {username}",
|
||||
values: { username: content.reader },
|
||||
}),
|
||||
)
|
||||
}`,
|
||||
];
|
||||
|
||||
const { activity, network, community } = content;
|
||||
if (activity) {
|
||||
md.push(
|
||||
`## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`,
|
||||
ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0)
|
||||
.map((k) =>
|
||||
figure(activity.counts[k]!, countLabel(k, activity.counts[k]!))
|
||||
)
|
||||
.join(" · "),
|
||||
activity.highlights.map((h) => {
|
||||
const target = h.kind === "mentionChat" || h.kind === "mentionEveryone"
|
||||
? mdLink(
|
||||
i18n._({ id: "email.digest.theChat", message: "the chat" }),
|
||||
content.links.chat,
|
||||
)
|
||||
: h.target?.url
|
||||
? mdLink(h.target.title, h.target.url)
|
||||
: h.target
|
||||
? escMd(h.target.title)
|
||||
: "";
|
||||
const line = `- ${
|
||||
highlightSentence(h.kind, `**@${escMd(h.who)}**`, target)
|
||||
} *${escMd(day(h.at))}*`;
|
||||
return h.quote ? `${line}\n > ${escMd(h.quote)}` : line;
|
||||
}).join("\n"),
|
||||
`${
|
||||
mdLink(
|
||||
i18n._({
|
||||
id: "email.digest.seeAll",
|
||||
message:
|
||||
"{n, plural, one {See the notification} other {See all # notifications}}",
|
||||
values: { n: activity.total },
|
||||
}),
|
||||
content.links.notifications,
|
||||
)
|
||||
} →`,
|
||||
);
|
||||
}
|
||||
|
||||
if (network) {
|
||||
md.push(
|
||||
`## ${
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.digest.network",
|
||||
message: "From people you follow",
|
||||
}),
|
||||
)
|
||||
}`,
|
||||
network.dumps.map((d) => dumpItem(d, "-")).join("\n"),
|
||||
);
|
||||
if (network.more > 0) {
|
||||
md.push(`${
|
||||
mdLink(
|
||||
i18n._({
|
||||
id: "email.digest.more",
|
||||
message: "{n} more on your Followed feed",
|
||||
values: { n: network.more },
|
||||
}),
|
||||
content.links.followed,
|
||||
)
|
||||
} →`);
|
||||
}
|
||||
}
|
||||
|
||||
if (content.top.length > 0) {
|
||||
const [first] = content.top;
|
||||
md.push(
|
||||
`## ${
|
||||
escMd(
|
||||
monthly
|
||||
? i18n._(DIGEST_MESSAGES.topMonth)
|
||||
: i18n._(DIGEST_MESSAGES.topWeek),
|
||||
)
|
||||
}`,
|
||||
);
|
||||
// The recap gives its first dump a wide image and the next two a thumbnail.
|
||||
if (monthly && first.thumbnail) md.push(``);
|
||||
md.push(
|
||||
content.top.map((d, i) =>
|
||||
dumpItem(d, `${i + 1}.`, monthly ? i === 1 || i === 2 : true)
|
||||
).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (community) {
|
||||
const { dumps, upvotes, comments } = community.totals;
|
||||
md.push(
|
||||
`## ${escMd(i18n._(DIGEST_MESSAGES.community))}`,
|
||||
);
|
||||
if (monthly) {
|
||||
md.push(
|
||||
[
|
||||
figure(
|
||||
dumps,
|
||||
i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } }),
|
||||
),
|
||||
figure(upvotes, countLabel("upvotes", upvotes)),
|
||||
figure(comments, countLabel("comments", comments)),
|
||||
figure(
|
||||
community.chatMessages,
|
||||
i18n._({
|
||||
...DIGEST_MESSAGES.statChat,
|
||||
values: { n: community.chatMessages },
|
||||
}),
|
||||
),
|
||||
].join(" · "),
|
||||
);
|
||||
}
|
||||
if (community.members.length > 0) {
|
||||
md.push(
|
||||
`### ${
|
||||
escMd(
|
||||
i18n._(DIGEST_MESSAGES.newMembers),
|
||||
)
|
||||
}`,
|
||||
[
|
||||
...community.members.map((m) =>
|
||||
`- **@${escMd(m.username)}**${
|
||||
m.invitedBy
|
||||
? ` ${escMd(invitedLine(m.invitedBy, content.reader))}`
|
||||
: ""
|
||||
}`
|
||||
),
|
||||
...(community.membersMore > 0
|
||||
? [`- ${escMd(andMoreLine(community.membersMore))}`]
|
||||
: []),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (community.playlists.length > 0) {
|
||||
md.push(
|
||||
`### ${escMd(i18n._(DIGEST_MESSAGES.newPlaylists))}`,
|
||||
[
|
||||
...community.playlists.map((p) =>
|
||||
`- ${mdLink(p.title, p.url)} · ${
|
||||
escMd(playlistMeta(p.owner, p.dumps))
|
||||
}`
|
||||
),
|
||||
...(community.playlistsMore > 0
|
||||
? [`- ${escMd(andMoreLine(community.playlistsMore))}`]
|
||||
: []),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (!monthly && community.chatMessages > 0) {
|
||||
md.push(
|
||||
`### ${
|
||||
escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" }))
|
||||
}`,
|
||||
`${
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.digest.chatWeek",
|
||||
message:
|
||||
"{n, plural, one {# message this week} other {# messages this week}}",
|
||||
values: { n: community.chatMessages },
|
||||
}),
|
||||
)
|
||||
} · ${
|
||||
mdLink(
|
||||
i18n._({ id: "email.digest.openChat", message: "Open the chat" }),
|
||||
content.links.chat,
|
||||
)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!monthly && dumps + upvotes + comments > 0) {
|
||||
md.push(
|
||||
`*${
|
||||
escMd(i18n._({
|
||||
id: "email.digest.totals",
|
||||
message:
|
||||
"{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.",
|
||||
values: { dumps, upvotes, comments },
|
||||
}))
|
||||
}*`,
|
||||
);
|
||||
}
|
||||
md.push(`${
|
||||
mdLink(
|
||||
i18n._({
|
||||
id: "email.digest.communityPage",
|
||||
message: "See it all on the community page",
|
||||
}),
|
||||
content.links.community,
|
||||
)
|
||||
} →`);
|
||||
}
|
||||
|
||||
md.push(
|
||||
"---",
|
||||
escMd(
|
||||
monthly
|
||||
? i18n._({
|
||||
id: "email.digest.monthly.reason",
|
||||
message:
|
||||
"You're getting the monthly recap because it's on in your settings.",
|
||||
})
|
||||
: i18n._({
|
||||
id: "email.digest.weekly.reason",
|
||||
message:
|
||||
"You're getting the weekly digest because it's on in your settings.",
|
||||
}),
|
||||
),
|
||||
`${
|
||||
mdLink(
|
||||
monthly
|
||||
? i18n._(DIGEST_MESSAGES.monthlyOff)
|
||||
: i18n._(DIGEST_MESSAGES.weeklyOff),
|
||||
content.links.unsubscribe,
|
||||
)
|
||||
} · ${
|
||||
mdLink(
|
||||
i18n._({ id: "email.settings", message: "Settings" }),
|
||||
content.links.settings,
|
||||
)
|
||||
}`,
|
||||
);
|
||||
|
||||
return new EmailKit(look).render({
|
||||
subject: monthly
|
||||
? i18n._({
|
||||
id: "email.digest.monthly.subject",
|
||||
message: "The month on {site}, {range}",
|
||||
values: { site: OG_SITE_NAME, range: range(false) },
|
||||
})
|
||||
: i18n._({
|
||||
id: "email.digest.weekly.subject",
|
||||
message: "Your week on {site}, {range}",
|
||||
values: { site: OG_SITE_NAME, range: range(false) },
|
||||
}),
|
||||
preheader: preheader(content),
|
||||
markdown: md.join("\n\n"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the line an inbox shows beside the subject: the counts that make
|
||||
* the digest worth opening, at most three of them.
|
||||
*/
|
||||
function preheader(content: DigestContent): string {
|
||||
const parts: string[] = [];
|
||||
if (content.cadence === "weekly") {
|
||||
for (const k of ["upvotes", "followers", "comments"] as const) {
|
||||
const n = content.activity?.counts[k] ?? 0;
|
||||
if (n > 0) parts.push(`${n} ${countLabel(k, n)}`);
|
||||
}
|
||||
const n = (content.network?.dumps.length ?? 0) +
|
||||
(content.network?.more ?? 0);
|
||||
if (n > 0) {
|
||||
parts.push(
|
||||
i18n._({
|
||||
id: "email.digest.pre.network",
|
||||
message:
|
||||
"{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}",
|
||||
values: { n },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const members = (content.community?.members.length ?? 0) +
|
||||
(content.community?.membersMore ?? 0);
|
||||
const playlists = (content.community?.playlists.length ?? 0) +
|
||||
(content.community?.playlistsMore ?? 0);
|
||||
if (content.top.length > 0) {
|
||||
parts.push(
|
||||
i18n._({
|
||||
id: "email.digest.pre.topMonth",
|
||||
message:
|
||||
"{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}",
|
||||
values: { n: content.top.length },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (members > 0) {
|
||||
parts.push(
|
||||
i18n._({
|
||||
id: "email.digest.pre.members",
|
||||
message: "{n, plural, one {# new member} other {# new members}}",
|
||||
values: { n: members },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (playlists > 0) {
|
||||
parts.push(
|
||||
i18n._({
|
||||
id: "email.digest.pre.playlists",
|
||||
message: "{n, plural, one {# new playlist} other {# new playlists}}",
|
||||
values: { n: playlists },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0 && content.top.length > 0) {
|
||||
parts.push(
|
||||
i18n._({
|
||||
id: "email.digest.pre.topWeek",
|
||||
message:
|
||||
"{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}",
|
||||
values: { n: content.top.length },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) return "";
|
||||
const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" })
|
||||
.format(parts.slice(0, 3));
|
||||
return `${line.charAt(0).toUpperCase()}${line.slice(1)}.`;
|
||||
}
|
||||
36
api/mail/i18n.ts
Normal file
36
api/mail/i18n.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Lets the emails speak the instance's language from the same catalogs as
|
||||
* the interface. A template passes each message as a descriptor holding its
|
||||
* id and its English, as `i18n._({ id: "email.reset.title", message: "Reset
|
||||
* your password" })`, or one of `shared/digest-messages.ts`, which a page
|
||||
* says too. `lingui extract` reads them into `locales/<lang>.po` beside the
|
||||
* interface's messages, and `lingui compile` turns the catalogs into the
|
||||
* `locales/<lang>.mjs` imported here, failing when a language lacks one. The
|
||||
* ids are named rather than hashed from the English, since the server runs
|
||||
* no Lingui macro.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type I18n, type Messages, setupI18n } from "@lingui/core";
|
||||
|
||||
import { EMAIL_LOCALE } from "../config.ts";
|
||||
import { LOCALES } from "../../shared/locale.ts";
|
||||
|
||||
const catalogs: [string, Messages][] = await Promise.all(
|
||||
LOCALES.map(async (locale) =>
|
||||
[
|
||||
locale,
|
||||
(await import(`../../locales/${locale}.mjs`)).messages as Messages,
|
||||
] as [string, Messages]
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Translates the emails' messages, in {@link EMAIL_LOCALE}. Every catalog is
|
||||
* loaded, so a test can activate another language.
|
||||
*/
|
||||
export const i18n: I18n = setupI18n({
|
||||
locale: EMAIL_LOCALE,
|
||||
messages: Object.fromEntries(catalogs),
|
||||
});
|
||||
822
api/mail/layout.ts
Normal file
822
api/mail/layout.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
/**
|
||||
* Every email the site sends shares one layout: the site's emoji and name,
|
||||
* then a card holding the content, then a footer. The templates beside this
|
||||
* module (welcome, password reset, digest) build their content with an
|
||||
* {@link EmailKit}, which draws each block in a style from `styles.ts`, and
|
||||
* `email-service.ts` sends what they return.
|
||||
*
|
||||
* Mail clients drop stylesheets, so every block carries inline styles. A
|
||||
* reader on the "auto" scheme gets the light palette inline and the dark one
|
||||
* in a `prefers-color-scheme` block that Apple's mail apps honour; elsewhere
|
||||
* the light one shows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Marked, Parser, type Token, type Tokens } from "marked";
|
||||
|
||||
import {
|
||||
DEFAULT_STYLE,
|
||||
EMAIL_LOCALE,
|
||||
OG_SITE_NAME,
|
||||
SITE_EMOJI,
|
||||
} from "../config.ts";
|
||||
import {
|
||||
type ColorScheme,
|
||||
DEFAULT_COLOR_SCHEME,
|
||||
type StyleName,
|
||||
} from "../../shared/theme.ts";
|
||||
import type { UserSettings } from "../../shared/settings.ts";
|
||||
import { EMAIL_STYLES, type EmailPalette, type EmailStyle } from "./styles.ts";
|
||||
|
||||
/**
|
||||
* Returned by each template to its caller, which hands it to `sendEmail`
|
||||
* with a recipient.
|
||||
*/
|
||||
export interface RenderedEmail {
|
||||
subject: string;
|
||||
html: string;
|
||||
/**
|
||||
* The plain-text part, for clients that show no HTML. It is Markdown, so it
|
||||
* reads as text and renders as the email's outline.
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to {@link EmailKit}, made by {@link lookOf}: the instance's style for
|
||||
* the emails that aren't a user's own, their settings for a digest.
|
||||
*/
|
||||
export interface EmailLook {
|
||||
style: StyleName;
|
||||
scheme: ColorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the look of a reader's settings. An unset style is the instance's,
|
||||
* as on the site, and an unset scheme the system's; with no settings at all,
|
||||
* as for the welcome and reset emails, both are.
|
||||
*/
|
||||
export function lookOf(
|
||||
settings: Pick<UserSettings, "style" | "colorScheme"> | null = null,
|
||||
): EmailLook {
|
||||
return {
|
||||
style: settings?.style ?? DEFAULT_STYLE,
|
||||
scheme: settings?.colorScheme ?? DEFAULT_COLOR_SCHEME,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes
|
||||
* it, so that the dark block can recolor the element by the same name.
|
||||
*/
|
||||
export type PaletteRole = Exclude<keyof EmailPalette, "gradient">;
|
||||
|
||||
const ROLES: PaletteRole[] = [
|
||||
"outer",
|
||||
"card",
|
||||
"text",
|
||||
"sec",
|
||||
"muted",
|
||||
"accent",
|
||||
"link",
|
||||
"onAccent",
|
||||
"rule",
|
||||
"soft",
|
||||
"quote",
|
||||
"date",
|
||||
"brand",
|
||||
"border",
|
||||
];
|
||||
|
||||
/**
|
||||
* Escapes `value` for HTML text and attributes. Everything users wrote (names,
|
||||
* titles, quotes) goes through it, since none of it may become markup.
|
||||
*/
|
||||
export function esc(value: string): string {
|
||||
return value.replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||||
c
|
||||
]!,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes `value` for Markdown, so that user text in a plain-text part (a
|
||||
* title with `*` or `[`, say) stays text.
|
||||
*/
|
||||
export function escMd(value: string): string {
|
||||
return value.replace(/[\\`*_[\]<>#!|~]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Returns a Markdown link to `url` labelled with `text`, escaped. */
|
||||
export function mdLink(text: string, url: string): string {
|
||||
const safeUrl = url.replace(
|
||||
/[()\s]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
|
||||
);
|
||||
return `[${escMd(text)}](${safeUrl})`;
|
||||
}
|
||||
|
||||
const gradient = (colors: string[]) =>
|
||||
`linear-gradient(90deg,${colors.join(",")})`;
|
||||
|
||||
/**
|
||||
* Draws an email in one {@link EmailLook}. A template writes the email as
|
||||
* Markdown and hands it to {@link EmailKit.render}, which draws each block in
|
||||
* the style. The block methods return HTML: text passed to them must already
|
||||
* be escaped with {@link esc}.
|
||||
*/
|
||||
export class EmailKit {
|
||||
/** The style being drawn. */
|
||||
readonly style: EmailStyle;
|
||||
/** The palette inlined into the HTML. */
|
||||
readonly palette: EmailPalette;
|
||||
/** The palette that replaces it in dark mode, on the "auto" scheme only. */
|
||||
readonly darkPalette: EmailPalette | null;
|
||||
/** The font stack, for templates that set their own blocks. */
|
||||
readonly font: string;
|
||||
|
||||
constructor(look: EmailLook) {
|
||||
this.style = EMAIL_STYLES[look.style];
|
||||
this.palette = look.scheme === "dark" ? this.style.dark : this.style.light;
|
||||
this.darkPalette = look.scheme === DEFAULT_COLOR_SCHEME
|
||||
? this.style.dark
|
||||
: null;
|
||||
this.font = this.style.font;
|
||||
}
|
||||
|
||||
/** Returns `px`, scaled for the style, as a CSS length. */
|
||||
fs(px: number): string {
|
||||
return `${Math.round(px * this.style.scale * 2) / 2}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `class` and `style` attributes of an element colored by
|
||||
* roles: `fg` for its text, `bg` for its fill, `bd` for its border. The
|
||||
* classes let the dark block recolor it; `css` is the rest of its style.
|
||||
*/
|
||||
paint(
|
||||
roles: {
|
||||
fg?: PaletteRole;
|
||||
bg?: PaletteRole;
|
||||
bd?: PaletteRole;
|
||||
extra?: string[];
|
||||
},
|
||||
css = "",
|
||||
): string {
|
||||
const p = this.palette;
|
||||
const classes: string[] = [...(roles.extra ?? [])];
|
||||
let style = "";
|
||||
if (roles.fg) {
|
||||
classes.push(`f-${roles.fg}`);
|
||||
style += `color:${p[roles.fg] ?? p.text};`;
|
||||
}
|
||||
if (roles.bg) {
|
||||
classes.push(`b-${roles.bg}`);
|
||||
style += `background-color:${p[roles.bg] ?? p.card};`;
|
||||
}
|
||||
if (roles.bd) {
|
||||
classes.push(`d-${roles.bd}`);
|
||||
style += `border-color:${p[roles.bd] ?? p.rule};`;
|
||||
}
|
||||
const cls = classes.length ? ` class="${classes.join(" ")}"` : "";
|
||||
// The colors come last: a shorthand such as `border-top` in `css` would
|
||||
// otherwise reset them.
|
||||
return `${cls} style="${css}${style}"`;
|
||||
}
|
||||
|
||||
/** Returns a link to `href` around `html`, in the link color. */
|
||||
link(html: string, href: string, strong = false): string {
|
||||
const s = this.style;
|
||||
return `<a href="${esc(href)}"${
|
||||
this.paint(
|
||||
{ fg: "link" },
|
||||
`text-decoration:${s.linkUnderline ? "underline" : "none"};${
|
||||
strong ? `font-weight:${s.heavy};` : ""
|
||||
}`,
|
||||
)
|
||||
}>${html}</a>`;
|
||||
}
|
||||
|
||||
/** Returns the email's heading, in the style's title type. */
|
||||
heading(html: string, marginBottom = 24): string {
|
||||
const t = this.style.title;
|
||||
return `<h1${
|
||||
this.paint(
|
||||
{ fg: t.accent ? "accent" : "text" },
|
||||
`font-family:${this.font};font-size:${
|
||||
this.fs(t.size)
|
||||
};line-height:1.15;font-weight:${t.weight};${
|
||||
t.upper ? "text-transform:uppercase;" : ""
|
||||
}${
|
||||
t.spacing ? `letter-spacing:${t.spacing};` : ""
|
||||
}margin:0 0 ${marginBottom}px;`,
|
||||
)
|
||||
}>${html}</h1>`;
|
||||
}
|
||||
|
||||
/** Returns a paragraph. `css` adds to or overrides its style. */
|
||||
para(html: string, css = ""): string {
|
||||
return `<p${
|
||||
this.paint(
|
||||
{ fg: "text" },
|
||||
`font-family:${this.font};font-size:${
|
||||
this.fs(15)
|
||||
};line-height:1.6;margin:0 0 16px;${css}`,
|
||||
)
|
||||
}>${html}</p>`;
|
||||
}
|
||||
|
||||
/** Returns a smaller paragraph in the muted color. */
|
||||
note(html: string, css = ""): string {
|
||||
return `<p${
|
||||
this.paint(
|
||||
{ fg: "muted" },
|
||||
`font-family:${this.font};font-size:${
|
||||
this.fs(13.5)
|
||||
};line-height:1.6;margin:0 0 16px;${css}`,
|
||||
)
|
||||
}>${html}</p>`;
|
||||
}
|
||||
|
||||
/** Returns a button linking to `href`. */
|
||||
button(label: string, href: string): string {
|
||||
const s = this.style;
|
||||
const frame = s.frame === "hard"
|
||||
? `border:2px solid ${this.palette.text};box-shadow:2px 2px 0 ${this.palette.text};`
|
||||
: "";
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr><td${
|
||||
this.paint(
|
||||
{
|
||||
bg: "accent",
|
||||
bd: s.frame === "hard" ? "text" : undefined,
|
||||
extra: s.frame === "hard" ? ["sh-btn"] : [],
|
||||
},
|
||||
`border-radius:${s.radius.btn}px;${frame}`,
|
||||
)
|
||||
}><a href="${esc(href)}"${
|
||||
this.paint(
|
||||
{ fg: "onAccent" },
|
||||
`display:inline-block;padding:13px 22px;font-family:${this.font};font-size:${
|
||||
this.fs(13.5)
|
||||
};font-weight:${
|
||||
Math.max(600, s.heavy)
|
||||
};letter-spacing:0.08em;text-transform:uppercase;line-height:1;text-decoration:none;`,
|
||||
)
|
||||
}>${label}</a></td></tr></table>`;
|
||||
}
|
||||
|
||||
/** Returns a small uppercase pill, as on one's own dump in a list. */
|
||||
pill(label: string): string {
|
||||
return `<span${
|
||||
this.paint(
|
||||
{ fg: "sec", bg: "soft" },
|
||||
`display:inline-block;margin-left:6px;padding:1px 7px;border-radius:${this.style.radius.pill}px;font-size:${
|
||||
this.fs(11)
|
||||
};font-weight:600;letter-spacing:0.08em;text-transform:uppercase;vertical-align:2px;`,
|
||||
)
|
||||
}>${label}</span>`;
|
||||
}
|
||||
|
||||
/** Returns an image block, rounded as the style rounds images. */
|
||||
image(
|
||||
src: string,
|
||||
alt: string,
|
||||
width: number,
|
||||
height: number,
|
||||
href?: string,
|
||||
): string {
|
||||
const img = `<img src="${
|
||||
esc(src)
|
||||
}" width="${width}" height="${height}" alt="${
|
||||
esc(alt)
|
||||
}" style="display:block;max-width:100%;height:auto;border:0;border-radius:${
|
||||
Math.min(this.style.radius.img, width / 8)
|
||||
}px;">`;
|
||||
return href ? `<a href="${esc(href)}">${img}</a>` : img;
|
||||
}
|
||||
|
||||
/** Returns a small bold heading inside a section. */
|
||||
subhead(html: string): string {
|
||||
return `<div${
|
||||
this.paint(
|
||||
{ fg: "text" },
|
||||
`font-family:${this.font};font-size:${this.fs(14)};font-weight:${
|
||||
Math.max(600, this.style.heavy)
|
||||
};margin:0 0 8px;`,
|
||||
)
|
||||
}>${html}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a section of the card: a divider in the style's rule (none before
|
||||
* the first), an uppercase title in the accent color, and `html`.
|
||||
*/
|
||||
section(title: string, html: string, first = false): string {
|
||||
const s = this.style;
|
||||
let divider = "";
|
||||
let border = "";
|
||||
if (!first) {
|
||||
const stops = this.palette.gradient;
|
||||
if (s.rule === "gradient" && stops) {
|
||||
divider =
|
||||
`<div class="grad" style="height:3px;line-height:3px;font-size:0;background-color:${
|
||||
stops[0]
|
||||
};background-image:${gradient(stops)};margin:0 0 26px;"> </div>`;
|
||||
} else {
|
||||
border = `border-top:${
|
||||
s.rule === "thick" ? 2 : 1
|
||||
}px solid;padding-top:26px;`;
|
||||
}
|
||||
}
|
||||
return `<div${
|
||||
this.paint(
|
||||
{ bd: border ? (s.rule === "thick" ? "text" : "rule") : undefined },
|
||||
`${border}padding-bottom:26px;`,
|
||||
)
|
||||
}>${divider}<div${
|
||||
this.paint(
|
||||
{ fg: "accent" },
|
||||
`font-family:${this.font};font-size:${this.fs(12)};font-weight:${
|
||||
Math.max(600, s.heavy)
|
||||
};letter-spacing:0.12em;text-transform:uppercase;margin:0 0 16px;`,
|
||||
)
|
||||
}>${title}</div>${html}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email written as `markdown`: the Markdown itself is the
|
||||
* plain-text part, and its blocks, drawn in this kit's style, are the HTML.
|
||||
* Each template writes its email once this way. The blocks map as follows.
|
||||
*
|
||||
* - `# Title` is the heading; an `*emphasised*` paragraph above it, the
|
||||
* eyebrow. Elsewhere an emphasised paragraph is a note.
|
||||
* - `## Section` starts a section, with its divider; `### Name` a subhead.
|
||||
* - A link alone in a paragraph is a button, an image alone a wide image,
|
||||
* and a fenced block a box for a raw link.
|
||||
* - A list item whose text starts with an image, then a link, is a row
|
||||
* with that thumbnail and the link as its title; what follows is its
|
||||
* detail, and an emphasised word right after the link a pill. Other items
|
||||
* are sentences, where emphasis marks the date. A numbered list ranks its
|
||||
* rows, and a quote under an item is set as one.
|
||||
* - A bold number is a figure.
|
||||
* - What follows the last `---` is the footer.
|
||||
*/
|
||||
render(
|
||||
{ subject, preheader, markdown }: {
|
||||
subject: string;
|
||||
preheader: string;
|
||||
markdown: string;
|
||||
},
|
||||
): RenderedEmail {
|
||||
const cut = markdown.lastIndexOf("\n---\n");
|
||||
const body = cut === -1 ? markdown : markdown.slice(0, cut);
|
||||
const foot = cut === -1 ? "" : markdown.slice(cut + 5);
|
||||
return {
|
||||
subject,
|
||||
html: this.document({
|
||||
subject,
|
||||
preheader,
|
||||
body: this.blocks(this.marked.lexer(body)),
|
||||
footer: this.marked.lexer(foot)
|
||||
.filter((t): t is Tokens.Paragraph => t.type === "paragraph")
|
||||
.map((t) => this.inline(t.tokens, "footer")).join("<br>"),
|
||||
}),
|
||||
text: markdown,
|
||||
};
|
||||
}
|
||||
|
||||
/** How {@link inline} draws emphasis and links, by where they sit. */
|
||||
private mode: "text" | "footer" | "row" | "sentence" = "text";
|
||||
|
||||
/** Draws Markdown's inline tokens with the kit's colors. */
|
||||
// The kit comes in as an argument: the renderer's methods need marked's
|
||||
// `this` (its parser) as well as the kit's colors.
|
||||
private readonly marked: Marked = ((kit: EmailKit) => {
|
||||
return new Marked({
|
||||
renderer: {
|
||||
link({ href, tokens }) {
|
||||
const html = this.parser.parseInline(tokens);
|
||||
return kit.mode === "footer"
|
||||
? `<a href="${esc(href)}"${
|
||||
kit.paint({ fg: "muted" }, "text-decoration:underline;")
|
||||
}>${html}</a>`
|
||||
: kit.link(html, href);
|
||||
},
|
||||
strong({ tokens }) {
|
||||
const html = this.parser.parseInline(tokens);
|
||||
// A figure, its digits grouped by any space: `\s` takes the no-break
|
||||
// ones locales group thousands with.
|
||||
const figure = /^[\d\s.,]+$/.test(html);
|
||||
return `<b${
|
||||
kit.paint(
|
||||
{ fg: "text" },
|
||||
figure
|
||||
? `font-size:${kit.fs(22)};font-weight:${kit.style.heavy};`
|
||||
: `font-weight:${Math.max(600, kit.style.heavy)};`,
|
||||
)
|
||||
}>${html}</b>`;
|
||||
},
|
||||
em({ tokens }) {
|
||||
const html = this.parser.parseInline(tokens);
|
||||
if (kit.mode === "row") return kit.pill(html);
|
||||
if (kit.mode === "sentence") {
|
||||
return `<span${
|
||||
kit.paint(
|
||||
{ fg: kit.palette.date ? "date" : "muted" },
|
||||
`font-size:${kit.fs(12.5)};white-space:nowrap;${
|
||||
kit.palette.date ? "font-style:italic;" : ""
|
||||
}`,
|
||||
)
|
||||
}>${html}</span>`;
|
||||
}
|
||||
return `<i>${html}</i>`;
|
||||
},
|
||||
codespan({ text }) {
|
||||
return `<code style="font-family:Menlo,Consolas,'Courier New',monospace;">${text}</code>`;
|
||||
},
|
||||
image({ href, text }) {
|
||||
return kit.image(href, text, 104, 78);
|
||||
},
|
||||
},
|
||||
});
|
||||
})(this);
|
||||
|
||||
private inline(tokens: Token[], mode: EmailKit["mode"]): string {
|
||||
this.mode = mode;
|
||||
try {
|
||||
return Parser.parseInline(tokens, this.marked.defaults);
|
||||
} finally {
|
||||
this.mode = "text";
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws the body's blocks, gathering each `##` section with its content. */
|
||||
private blocks(tokens: Token[]): string {
|
||||
const out: string[] = [];
|
||||
let section: { title: string; parts: string[] } | null = null;
|
||||
let first = true;
|
||||
let headed = false;
|
||||
const push = (html: string) =>
|
||||
section ? section.parts.push(html) : out.push(html);
|
||||
const close = () => {
|
||||
if (!section) return;
|
||||
out.push(this.section(section.title, section.parts.join(""), first));
|
||||
first = false;
|
||||
section = null;
|
||||
};
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "heading": {
|
||||
const html = this.inline(token.tokens ?? [], "text");
|
||||
if (token.depth === 1) {
|
||||
headed = true;
|
||||
push(this.heading(html));
|
||||
} else if (token.depth === 2) {
|
||||
close();
|
||||
section = { title: html, parts: [] };
|
||||
} else {
|
||||
push(this.subhead(html));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "paragraph": {
|
||||
const kids = (token.tokens ?? []).filter((t) =>
|
||||
!(t.type === "text" && !t.raw.trim())
|
||||
);
|
||||
const only = kids.length === 1 ? kids[0] : null;
|
||||
if (only?.type === "link") {
|
||||
push(
|
||||
this.button(this.inline(only.tokens ?? [], "text"), only.href),
|
||||
);
|
||||
} else if (only?.type === "image") {
|
||||
push(
|
||||
`${
|
||||
this.image(only.href, only.text, 528, 264)
|
||||
}<div style="height:14px;line-height:14px;font-size:0;"> </div>`,
|
||||
);
|
||||
} else if (only?.type === "em") {
|
||||
const html = this.inline(only.tokens ?? [], "text");
|
||||
push(headed ? this.note(html) : this.eyebrow(html));
|
||||
} else {
|
||||
push(this.para(this.inline(token.tokens ?? [], "text")));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
push(this.list(token as Tokens.List));
|
||||
break;
|
||||
case "blockquote":
|
||||
push(this.quote(this.inline(
|
||||
(token.tokens ?? []).flatMap((t) =>
|
||||
"tokens" in t ? t.tokens ?? [] : []
|
||||
),
|
||||
"text",
|
||||
)));
|
||||
break;
|
||||
case "code":
|
||||
push(this.linkBox(esc(token.text)));
|
||||
break;
|
||||
case "html":
|
||||
push(token.text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
close();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/** Draws a list as rows, ranked when it is numbered. */
|
||||
private list(list: Tokens.List): string {
|
||||
const start = typeof list.start === "number" ? list.start : 1;
|
||||
return list.items.map((item, i) => {
|
||||
const text = item.tokens.find((t) =>
|
||||
t.type === "text" || t.type === "paragraph"
|
||||
) as Tokens.Text | Tokens.Paragraph | undefined;
|
||||
const quote = item.tokens.find((t) => t.type === "blockquote") as
|
||||
| Tokens.Blockquote
|
||||
| undefined;
|
||||
let rest: Token[] = [...(text?.tokens ?? [])];
|
||||
const skipSpace = () => {
|
||||
while (rest[0]?.type === "text" && !rest[0].raw.trim()) {
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
};
|
||||
skipSpace();
|
||||
const image = rest[0]?.type === "image"
|
||||
? rest.shift() as Tokens.Image
|
||||
: null;
|
||||
skipSpace();
|
||||
const link = rest[0]?.type === "link"
|
||||
? rest.shift() as Tokens.Link
|
||||
: null;
|
||||
|
||||
let content: string;
|
||||
if (link) {
|
||||
const pills: Token[] = [];
|
||||
skipSpace();
|
||||
while (rest[0]?.type === "em") {
|
||||
pills.push(rest.shift()!);
|
||||
skipSpace();
|
||||
}
|
||||
// The detail follows the title after a " · ".
|
||||
if (rest[0]?.type === "text") {
|
||||
const t = rest[0] as Tokens.Text;
|
||||
const trimmed = t.text.replace(/^\s*·\s*/, "");
|
||||
rest[0] = { ...t, text: trimmed, raw: trimmed, tokens: undefined };
|
||||
}
|
||||
content = `<a href="${esc(link.href)}"${
|
||||
this.paint(
|
||||
{ fg: "text" },
|
||||
`font-size:${
|
||||
this.fs(15)
|
||||
};line-height:1.35;font-weight:${this.style.heavy};text-decoration:none;`,
|
||||
)
|
||||
}>${this.inline(link.tokens, "text")}</a>${this.inline(pills, "row")}${
|
||||
rest.length
|
||||
? `<div${
|
||||
this.paint(
|
||||
{ fg: "muted" },
|
||||
`font-size:${this.fs(13)};line-height:1.5;margin-top:3px;`,
|
||||
)
|
||||
}>${this.inline(rest, "text")}</div>`
|
||||
: ""
|
||||
}`;
|
||||
} else {
|
||||
content = `<div${
|
||||
this.paint(
|
||||
{ fg: "sec" },
|
||||
`font-size:${this.fs(14.5)};line-height:1.5;`,
|
||||
)
|
||||
}>${this.inline(rest, "sentence")}</div>`;
|
||||
}
|
||||
if (quote) {
|
||||
content += this.quote(this.inline(
|
||||
quote.tokens.flatMap((t) => "tokens" in t ? t.tokens ?? [] : []),
|
||||
"text",
|
||||
));
|
||||
}
|
||||
|
||||
const size = list.ordered ? [64, 48] : [104, 78];
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 ${
|
||||
link || image ? 16 : 10
|
||||
}px;"><tr>${
|
||||
list.ordered
|
||||
? `<td width="30" valign="top"${
|
||||
this.paint(
|
||||
{ fg: "accent" },
|
||||
`font-family:${this.font};font-size:${this.fs(20)};font-weight:${
|
||||
Math.max(600, this.style.heavy)
|
||||
};line-height:1.2;`,
|
||||
)
|
||||
}>${start + i}</td>`
|
||||
: ""
|
||||
}${
|
||||
image
|
||||
? `<td width="${size[0]}" valign="top" style="padding-right:${
|
||||
list.ordered ? 12 : 14
|
||||
}px;">${
|
||||
this.image(image.href, image.text, size[0], size[1], link?.href)
|
||||
}</td>`
|
||||
: ""
|
||||
}<td valign="top" style="font-family:${this.font};">${content}</td></tr></table>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/** Returns the small uppercase line above the heading. */
|
||||
private eyebrow(html: string): string {
|
||||
return `<div${
|
||||
this.paint(
|
||||
{ fg: "muted" },
|
||||
`font-family:${this.font};font-size:${
|
||||
this.fs(12)
|
||||
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
|
||||
)
|
||||
}>${html}</div>`;
|
||||
}
|
||||
|
||||
/** Returns a quoted comment or message. */
|
||||
private quote(html: string): string {
|
||||
return `<div${
|
||||
this.paint(
|
||||
{ fg: "quote" },
|
||||
`font-family:${this.font};font-size:${
|
||||
this.fs(14.5)
|
||||
};line-height:1.5;font-style:italic;margin:4px 0 0;`,
|
||||
)
|
||||
}>“${html}”</div>`;
|
||||
}
|
||||
|
||||
/** Returns a raw link set in a box, for clients that break buttons. */
|
||||
private linkBox(html: string): string {
|
||||
return `<div${
|
||||
this.paint(
|
||||
{ fg: "sec", bg: "soft" },
|
||||
`font-family:Menlo,Consolas,'Courier New',monospace;font-size:12px;line-height:1.5;border-radius:${
|
||||
Math.min(this.style.radius.img, 6)
|
||||
}px;padding:10px 12px;margin:0 0 22px;word-break:break-all;`,
|
||||
)
|
||||
}>${html}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the whole HTML document: the brand, the card holding `body`, and
|
||||
* the `footer`. `preheader` is the hidden first line inboxes show beside the
|
||||
* subject.
|
||||
*/
|
||||
private document(
|
||||
{ subject, preheader, body, footer }: {
|
||||
subject: string;
|
||||
preheader: string;
|
||||
body: string;
|
||||
footer: string;
|
||||
},
|
||||
): string {
|
||||
const s = this.style;
|
||||
const p = this.palette;
|
||||
const R = s.radius.card;
|
||||
const bar = s.bar ? p.gradient ?? null : null;
|
||||
const frame = s.frame === "hard"
|
||||
? `border:2px solid ${p.text};box-shadow:5px 5px 0 ${p.text};`
|
||||
: s.frame === "ridge"
|
||||
? `border:3px ridge ${p.border};`
|
||||
: s.frame === "hairline"
|
||||
? `border:1px solid ${p.rule};`
|
||||
: s.frame === "soft"
|
||||
? `box-shadow:0 14px 34px -10px ${p.shadow};`
|
||||
: "";
|
||||
const frameRoles = s.frame === "hard"
|
||||
? { bd: "text" as const, extra: ["sh-card"] }
|
||||
: s.frame === "ridge"
|
||||
? { bd: "border" as const }
|
||||
: s.frame === "hairline"
|
||||
? { bd: "rule" as const }
|
||||
: s.frame === "soft"
|
||||
? { extra: ["sh-soft"] }
|
||||
: {};
|
||||
const scheme = this.darkPalette
|
||||
? "light dark"
|
||||
: p === s.dark
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="${EMAIL_LOCALE}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="${scheme}">
|
||||
<meta name="supported-color-schemes" content="${scheme}">
|
||||
<title>${esc(subject)}</title>
|
||||
${
|
||||
s.webFont
|
||||
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${s.webFont}&display=swap">`
|
||||
: ""
|
||||
}
|
||||
<style>
|
||||
body{margin:0;padding:0;}
|
||||
@media (max-width:620px){.wrap{padding:20px 10px 28px!important}.card{padding:24px 20px 8px!important}}
|
||||
${this.darkCss()}
|
||||
</style>
|
||||
</head>
|
||||
<body${this.paint({ bg: "outer" }, "margin:0;padding:0;")}>
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${
|
||||
esc(preheader)
|
||||
}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"${
|
||||
this.paint({ bg: "outer" })
|
||||
}>
|
||||
<tr><td align="center" class="wrap" style="padding:32px 16px 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
|
||||
<tr><td style="padding:0 6px 16px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td style="padding-right:10px;vertical-align:middle;font-size:24px;line-height:1;">${
|
||||
esc(SITE_EMOJI)
|
||||
}</td>
|
||||
<td${
|
||||
this.paint(
|
||||
{ fg: p.brand ? "brand" : "text" },
|
||||
`vertical-align:middle;font-family:${this.font};font-weight:700;font-size:${
|
||||
this.fs(21)
|
||||
};line-height:1;letter-spacing:-0.01em;`,
|
||||
)
|
||||
}>${esc(OG_SITE_NAME)}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
${
|
||||
bar
|
||||
? `<tr><td class="grad" style="height:5px;line-height:5px;font-size:0;background-color:${
|
||||
bar[0]
|
||||
};background-image:${
|
||||
gradient(bar)
|
||||
};border-radius:${R}px ${R}px 0 0;"> </td></tr>`
|
||||
: ""
|
||||
}
|
||||
<tr><td${
|
||||
this.paint(
|
||||
{
|
||||
bg: "card",
|
||||
...frameRoles,
|
||||
extra: ["card", ...(frameRoles.extra ?? [])],
|
||||
},
|
||||
`border-radius:${
|
||||
bar ? `0 0 ${R}px ${R}px` : `${R}px`
|
||||
};${frame}padding:34px 36px 12px;`,
|
||||
)
|
||||
}>
|
||||
${body}
|
||||
</td></tr>
|
||||
<tr><td${
|
||||
this.paint(
|
||||
{ fg: "muted" },
|
||||
`padding:22px 6px 0;font-family:${this.font};font-size:${
|
||||
this.fs(12.5)
|
||||
};line-height:1.6;`,
|
||||
)
|
||||
}>${footer}</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSS that recolors every painted element in dark mode, or
|
||||
* nothing off the "auto" scheme.
|
||||
*/
|
||||
private darkCss(): string {
|
||||
const d = this.darkPalette;
|
||||
if (!d) return "";
|
||||
const rules: string[] = [];
|
||||
for (const role of ROLES) {
|
||||
const color = d[role];
|
||||
if (!color) continue;
|
||||
rules.push(
|
||||
`.f-${role}{color:${color}!important}`,
|
||||
`.b-${role}{background-color:${color}!important}`,
|
||||
`.d-${role}{border-color:${color}!important}`,
|
||||
);
|
||||
}
|
||||
rules.push(
|
||||
`.sh-card{box-shadow:5px 5px 0 ${d.text}!important}`,
|
||||
`.sh-btn{box-shadow:2px 2px 0 ${d.text}!important}`,
|
||||
);
|
||||
if (d.shadow) {
|
||||
rules.push(
|
||||
`.sh-soft{box-shadow:0 14px 34px -10px ${d.shadow}!important}`,
|
||||
);
|
||||
}
|
||||
if (d.gradient) {
|
||||
rules.push(
|
||||
`.grad{background-color:${d.gradient[0]}!important;background-image:${
|
||||
gradient(d.gradient)
|
||||
}!important}`,
|
||||
);
|
||||
}
|
||||
return `@media (prefers-color-scheme:dark){${rules.join("")}}`;
|
||||
}
|
||||
}
|
||||
92
api/mail/password-reset.ts
Normal file
92
api/mail/password-reset.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Renders the email carrying a password reset link. `requestPasswordReset`
|
||||
* (`password-reset-service.ts`) sends it, in the instance's style and
|
||||
* language: whoever asks isn't signed in, so their own settings don't apply.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { OG_SITE_NAME } from "../config.ts";
|
||||
import { i18n } from "./i18n.ts";
|
||||
import {
|
||||
EmailKit,
|
||||
escMd,
|
||||
lookOf,
|
||||
mdLink,
|
||||
type RenderedEmail,
|
||||
} from "./layout.ts";
|
||||
|
||||
/**
|
||||
* Returns the reset email of `username`, whose button opens `resetUrl`. The
|
||||
* link stays usable for `hours`, which the email states.
|
||||
*/
|
||||
export function renderPasswordResetEmail(
|
||||
username: string,
|
||||
resetUrl: string,
|
||||
hours: number,
|
||||
): RenderedEmail {
|
||||
const markdown = [
|
||||
`# ${
|
||||
escMd(i18n._({ id: "email.reset.title", message: "Reset your password" }))
|
||||
}`,
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.reset.hi",
|
||||
message: "Hi {username},",
|
||||
values: { username },
|
||||
}),
|
||||
),
|
||||
escMd(i18n._({
|
||||
id: "email.reset.body",
|
||||
message:
|
||||
"Someone asked to reset the password of your {site} account. The button below lets you choose a new one. It works once, for {hours, plural, one {# hour} other {# hours}}.",
|
||||
values: { site: OG_SITE_NAME, hours },
|
||||
})),
|
||||
mdLink(
|
||||
i18n._({ id: "email.reset.button", message: "Reset password" }),
|
||||
resetUrl,
|
||||
),
|
||||
`*${
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.reset.fallback",
|
||||
message:
|
||||
"If the button doesn't work, paste this link into your browser:",
|
||||
}),
|
||||
)
|
||||
}*`,
|
||||
"```\n" + resetUrl + "\n```",
|
||||
`*${
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.reset.ignore",
|
||||
message:
|
||||
"If you didn't ask for this, ignore this email. Your password stays the same.",
|
||||
}),
|
||||
)
|
||||
}*`,
|
||||
"---",
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.reset.reason",
|
||||
message:
|
||||
"You're getting this email because a password reset was requested for this address.",
|
||||
}),
|
||||
),
|
||||
].join("\n\n");
|
||||
|
||||
return new EmailKit(lookOf()).render({
|
||||
subject: i18n._({
|
||||
id: "email.reset.subject",
|
||||
message: "Reset your {site} password",
|
||||
values: { site: OG_SITE_NAME },
|
||||
}),
|
||||
preheader: i18n._({
|
||||
id: "email.reset.preheader",
|
||||
message:
|
||||
"The link works once, for {hours, plural, one {# hour} other {# hours}}.",
|
||||
values: { hours },
|
||||
}),
|
||||
markdown,
|
||||
});
|
||||
}
|
||||
253
api/mail/styles.ts
Normal file
253
api/mail/styles.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Carries each site style into email, so a digest looks like the site its
|
||||
* reader chose and the other emails like the instance's style. A mail client
|
||||
* takes no stylesheet of ours, so each style is resolved here into plain
|
||||
* values, which `layout.ts` inlines.
|
||||
*
|
||||
* What a theme defines is read from it through `theme-tokens.ts`, never
|
||||
* copied: the colors, the gradient, the font and the corner radii. What is
|
||||
* the email's own is stated here: how large it sets its type, how its card
|
||||
* stands out, which divider it draws, and which of the theme's own tokens
|
||||
* carry the touches that make it recognisable (Kanagawa's syntax hues,
|
||||
* GeoCities' rainbow, Melange's spice and warm shadow).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { STYLE_NAMES, type StyleName } from "../../shared/theme.ts";
|
||||
import { readTokens, type Scheme } from "./theme-tokens.ts";
|
||||
|
||||
/**
|
||||
* The colors of one style in one scheme, by role, each read from a token of
|
||||
* its theme. The optional ones carry a style's particular touches.
|
||||
*/
|
||||
export interface EmailPalette {
|
||||
/** The page around the card (`--color-bg`). */
|
||||
outer: string;
|
||||
/** The card holding the content (`--color-surface`). */
|
||||
card: string;
|
||||
/** `--color-text`. */
|
||||
text: string;
|
||||
/** `--color-text-secondary`. */
|
||||
sec: string;
|
||||
/** `--color-text-muted`. */
|
||||
muted: string;
|
||||
/** Section titles, ranks and button fills (`--color-accent`). */
|
||||
accent: string;
|
||||
/** `--color-link`. */
|
||||
link: string;
|
||||
/** Text on an accent fill (`--color-on-accent`). */
|
||||
onAccent: string;
|
||||
/** Dividers and hairlines (`--color-border`). */
|
||||
rule: string;
|
||||
/** Pills and the reset link's box, as the site's badges (`--color-badge-bg`). */
|
||||
soft: string;
|
||||
/** Quoted comments and messages (`--color-text-secondary` unless set). */
|
||||
quote: string;
|
||||
/** Kanagawa's fujiGray dates; others use `muted`. */
|
||||
date?: string;
|
||||
/** Kanagawa's oniViolet wordmark; others use `text`. */
|
||||
brand?: string;
|
||||
/** GeoCities' ridged border. */
|
||||
border?: string;
|
||||
/** Melange's warm card shadow, translucent. */
|
||||
shadow?: string;
|
||||
/** The colors of GeoCities' rainbow or Melange's spice, left to right. */
|
||||
gradient?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a style draws an email. `layout.ts` builds every block from it, so
|
||||
* adding a style to `shared/theme.ts` means adding its {@link Recipe} here.
|
||||
*/
|
||||
export interface EmailStyle {
|
||||
/**
|
||||
* The theme's font stack (`--font-body`). Its first family is a web font
|
||||
* only Apple's mail apps load, through {@link webFont}; other clients fall
|
||||
* back along the stack.
|
||||
*/
|
||||
font: string;
|
||||
/** The Google Fonts `family` parameter of the web font, if it is one. */
|
||||
webFont?: string;
|
||||
/** Multiplies every font size: Times sets its serif larger, Kanagawa its monospace smaller. */
|
||||
scale: number;
|
||||
/**
|
||||
* Corner radii, in pixels: the card's as the site's cards
|
||||
* (`--radius-xl`), the images' as its thumbnails (`--radius-md`), the
|
||||
* buttons' as its buttons (`--radius-lg`), the pills' as its badges
|
||||
* (`--radius-sm`). The avatars' is the email's own, since the site rounds
|
||||
* them with a literal 50% that some styles square.
|
||||
*/
|
||||
radius: {
|
||||
card: number;
|
||||
img: number;
|
||||
btn: number;
|
||||
pill: number;
|
||||
avatar: number;
|
||||
};
|
||||
/** The heading: its size in pixels, weight, and Brutalist's uppercase and spacing. */
|
||||
title: {
|
||||
size: number;
|
||||
weight: number;
|
||||
upper?: boolean;
|
||||
spacing?: string;
|
||||
accent?: boolean;
|
||||
};
|
||||
/** The weight of titles of items, figures and names. */
|
||||
heavy: number;
|
||||
/** How the card stands out from the page. */
|
||||
frame: "none" | "hard" | "ridge" | "hairline" | "soft";
|
||||
/** The divider between sections; "gradient" draws the palette's. */
|
||||
rule: "hairline" | "thick" | "gradient";
|
||||
/** Draws the palette's gradient as a bar along the card's top. */
|
||||
bar?: boolean;
|
||||
/** Underlines links, for styles whose links are the color of the text. */
|
||||
linkUnderline?: boolean;
|
||||
light: EmailPalette;
|
||||
dark: EmailPalette;
|
||||
}
|
||||
|
||||
/** The roles every style reads, and the token each is read from. */
|
||||
const PALETTE_TOKENS = {
|
||||
outer: "--color-bg",
|
||||
card: "--color-surface",
|
||||
text: "--color-text",
|
||||
sec: "--color-text-secondary",
|
||||
muted: "--color-text-muted",
|
||||
accent: "--color-accent",
|
||||
link: "--color-link",
|
||||
onAccent: "--color-on-accent",
|
||||
rule: "--color-border",
|
||||
soft: "--color-badge-bg",
|
||||
quote: "--color-text-secondary",
|
||||
} satisfies Partial<Record<keyof EmailPalette, string>>;
|
||||
|
||||
/**
|
||||
* What a style states about its email, beside what its theme defines: the
|
||||
* {@link EmailStyle} less the values read from the theme, the axes of its
|
||||
* web font, its avatars' radius, and the tokens of its own touches.
|
||||
*/
|
||||
interface Recipe
|
||||
extends Omit<EmailStyle, "font" | "webFont" | "radius" | "light" | "dark"> {
|
||||
/** The Google Fonts axes of the theme's first family; none for a system font. */
|
||||
webFontAxes?: string;
|
||||
avatarRadius: number;
|
||||
/** The tokens of the palette's optional roles, or of a base role it reads elsewhere. */
|
||||
tokens?: Partial<Record<keyof EmailPalette, string>>;
|
||||
}
|
||||
|
||||
const RECIPES: Record<StyleName, Recipe> = {
|
||||
smooth: {
|
||||
webFontAxes: "wght@400;600;700",
|
||||
scale: 1,
|
||||
avatarRadius: 16,
|
||||
title: { size: 30, weight: 600 },
|
||||
heavy: 600,
|
||||
frame: "none",
|
||||
rule: "hairline",
|
||||
},
|
||||
brutalist: {
|
||||
webFontAxes: "wght@400;700",
|
||||
scale: 1,
|
||||
avatarRadius: 0,
|
||||
title: { size: 30, weight: 900, upper: true, spacing: "0.03em" },
|
||||
heavy: 700,
|
||||
frame: "hard",
|
||||
rule: "thick",
|
||||
},
|
||||
geocities: {
|
||||
scale: 1,
|
||||
avatarRadius: 0,
|
||||
title: { size: 30, weight: 700, accent: true },
|
||||
heavy: 700,
|
||||
frame: "ridge",
|
||||
rule: "gradient",
|
||||
bar: true,
|
||||
linkUnderline: true,
|
||||
tokens: { border: "--color-border", gradient: "--geo-rainbow" },
|
||||
},
|
||||
nyt: {
|
||||
webFontAxes: "ital@0;1",
|
||||
scale: 1.12,
|
||||
avatarRadius: 16,
|
||||
title: { size: 38, weight: 400 },
|
||||
heavy: 400,
|
||||
frame: "hairline",
|
||||
rule: "hairline",
|
||||
linkUnderline: true,
|
||||
},
|
||||
kanagawa: {
|
||||
webFontAxes: "ital,wght@0,400;0,600;1,400",
|
||||
scale: 0.9,
|
||||
avatarRadius: 4,
|
||||
title: { size: 26, weight: 600 },
|
||||
heavy: 600,
|
||||
frame: "hairline",
|
||||
rule: "hairline",
|
||||
tokens: {
|
||||
quote: "--kngw-string",
|
||||
date: "--kngw-comment",
|
||||
brand: "--kngw-keyword",
|
||||
},
|
||||
},
|
||||
melange: {
|
||||
webFontAxes: "wght@400;600",
|
||||
scale: 1,
|
||||
avatarRadius: 16,
|
||||
title: { size: 32, weight: 600 },
|
||||
heavy: 600,
|
||||
frame: "soft",
|
||||
rule: "hairline",
|
||||
bar: true,
|
||||
tokens: { shadow: "--shadow-color", gradient: "--mel-spice" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the palette of `style` in `scheme` from its theme's tokens. Every
|
||||
* color is flattened onto the card, as a translucent one shows on the site,
|
||||
* but the shadow, which falls on the page.
|
||||
*/
|
||||
function readPalette(
|
||||
style: StyleName,
|
||||
scheme: Scheme,
|
||||
own: Recipe["tokens"] = {},
|
||||
): EmailPalette {
|
||||
const tokens = readTokens(style, scheme);
|
||||
const roles = { ...PALETTE_TOKENS, ...own };
|
||||
const palette = Object.fromEntries(
|
||||
Object.entries(roles)
|
||||
.filter(([role]) => role !== "shadow" && role !== "gradient")
|
||||
.map(([role, token]) => [role, tokens.color(token, "--color-surface")]),
|
||||
) as unknown as EmailPalette;
|
||||
if (own.shadow) palette.shadow = tokens.color(own.shadow);
|
||||
if (own.gradient) palette.gradient = tokens.stops(own.gradient);
|
||||
return palette;
|
||||
}
|
||||
|
||||
/** Returns the email look of `style`, its theme read in both schemes. */
|
||||
function readStyle(style: StyleName): EmailStyle {
|
||||
const { webFontAxes, avatarRadius, tokens: own, ...recipe } = RECIPES[style];
|
||||
const tokens = readTokens(style, "light");
|
||||
const font = tokens.font("--font-body");
|
||||
const family = font.split(",")[0].replace(/'/g, "").replaceAll(" ", "+");
|
||||
return {
|
||||
...recipe,
|
||||
font,
|
||||
webFont: webFontAxes ? `${family}:${webFontAxes}` : undefined,
|
||||
radius: {
|
||||
card: tokens.px("--radius-xl"),
|
||||
img: tokens.px("--radius-md"),
|
||||
btn: tokens.px("--radius-lg"),
|
||||
pill: tokens.px("--radius-sm"),
|
||||
avatar: avatarRadius,
|
||||
},
|
||||
light: readPalette(style, "light", own),
|
||||
dark: readPalette(style, "dark", own),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every style's email look, keyed by its name, read once at load. */
|
||||
export const EMAIL_STYLES = Object.fromEntries(
|
||||
STYLE_NAMES.map((style) => [style, readStyle(style)]),
|
||||
) as Record<StyleName, EmailStyle>;
|
||||
276
api/mail/theme-tokens.ts
Normal file
276
api/mail/theme-tokens.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Lets the emails draw with the site's own values. It reads a style's design
|
||||
* tokens out of the stylesheets the client loads, `src/tokens.css` and then
|
||||
* `src/themes/<style>.css`, so `styles.ts` names a token wherever it needs a
|
||||
* color, a gradient, a font or a radius, and a change to a theme reaches its
|
||||
* emails with nothing copied to keep in step.
|
||||
*
|
||||
* Mail clients know no custom properties, so a token comes back resolved for
|
||||
* one scheme: its `var()`s followed, its `light-dark()` picked, its
|
||||
* `color-mix()` computed. Only the rules that set tokens on the root element
|
||||
* are read (`:root`, the style's `[data-style]`, and either of those with the
|
||||
* scheme's `[data-color-scheme]`); a theme states its forced schemes that way
|
||||
* beside each `prefers-color-scheme` block, so the media blocks are skipped.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { BASE_STYLE, type StyleName } from "../../shared/theme.ts";
|
||||
import { hexOf, parseCssColor, type Rgba } from "../lib/css-color.ts";
|
||||
|
||||
/** A scheme a token is resolved for. "auto" is drawn from both. */
|
||||
export type Scheme = "light" | "dark";
|
||||
|
||||
/**
|
||||
* Returned by {@link readTokens}: the tokens of one style in one scheme, read
|
||||
* as the kind of value each is.
|
||||
*/
|
||||
export interface ThemeTokens {
|
||||
/** Returns the resolved value of `token`, or throws when it isn't set. */
|
||||
value(token: string): string;
|
||||
/**
|
||||
* Returns the color of `token` as `#rrggbb`. A translucent one is laid over
|
||||
* `over` (another token) first, as it shows on the site; without `over` it
|
||||
* stays `rgba()`, as a shadow's must.
|
||||
*/
|
||||
color(token: string, over?: string): string;
|
||||
/** Returns the colors of the gradient `token`, in order, without positions. */
|
||||
stops(token: string): string[];
|
||||
/**
|
||||
* Returns the font stack of `token`, single-quoted for a `style` attribute.
|
||||
* A family the site bundles as "X Variable" is named "X", as a mail client
|
||||
* or Google Fonts knows it.
|
||||
*/
|
||||
font(token: string): string;
|
||||
/** Returns the length of `token` in pixels. */
|
||||
px(token: string): number;
|
||||
}
|
||||
|
||||
const SOURCES = ["../../src/tokens.css", "../../src/themes/{style}.css"];
|
||||
|
||||
/** Returns `css` without its comments. */
|
||||
function stripComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
}
|
||||
|
||||
/** Returns the top-level rules of `css` as their selector and body. */
|
||||
function topRules(css: string): { selector: string; body: string }[] {
|
||||
const rules = [];
|
||||
let i = 0;
|
||||
while (i < css.length) {
|
||||
const open = css.indexOf("{", i);
|
||||
if (open < 0) break;
|
||||
let depth = 1;
|
||||
let j = open + 1;
|
||||
while (depth && j < css.length) {
|
||||
if (css[j] === "{") depth++;
|
||||
else if (css[j] === "}") depth--;
|
||||
j++;
|
||||
}
|
||||
rules.push({
|
||||
selector: css.slice(i, open).trim(),
|
||||
body: css.slice(open + 1, j - 1),
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `text` at each `sep` outside parentheses and quotes, trimming the
|
||||
* parts: the arguments of a CSS function, or the declarations of a rule.
|
||||
*/
|
||||
function splitTop(text: string, sep: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = "";
|
||||
} else if (c === '"' || c === "'") quote = c;
|
||||
else if (c === "(") depth++;
|
||||
else if (c === ")") depth--;
|
||||
else if (c === sep && depth === 0) {
|
||||
parts.push(text.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(start).trim());
|
||||
return parts.filter((part) => part !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each call of the CSS function `name` in `value`, innermost last
|
||||
* first, with what `replace` returns for its arguments.
|
||||
*/
|
||||
function replaceCalls(
|
||||
value: string,
|
||||
name: string,
|
||||
replace: (args: string[]) => string,
|
||||
): string {
|
||||
for (;;) {
|
||||
const at = value.lastIndexOf(`${name}(`);
|
||||
if (at < 0) return value;
|
||||
let depth = 0;
|
||||
let end = at + name.length;
|
||||
do {
|
||||
if (value[end] === "(") depth++;
|
||||
else if (value[end] === ")") depth--;
|
||||
end++;
|
||||
} while (depth && end < value.length);
|
||||
const args = splitTop(value.slice(at + name.length + 1, end - 1), ",");
|
||||
value = value.slice(0, at) + replace(args) + value.slice(end);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a CSS color in the forms the themes write, or throws. */
|
||||
function parseColor(text: string): Rgba {
|
||||
const color = parseCssColor(text);
|
||||
if (!color) throw new Error(`Unreadable color: ${text}`);
|
||||
return color;
|
||||
}
|
||||
|
||||
/** Writes `color` as `#rrggbb`, or as `rgba()` when it is translucent. */
|
||||
function formatColor(color: Rgba): string {
|
||||
if (color.a >= 1) return hexOf(color);
|
||||
const [r, g, b] = [color.r, color.g, color.b].map(Math.round);
|
||||
return `rgba(${r},${g},${b},${+color.a.toFixed(3)})`;
|
||||
}
|
||||
|
||||
/** Returns `top` laid over `under`, both straight (not premultiplied) alpha. */
|
||||
function over(top: Rgba, under: Rgba): Rgba {
|
||||
const a = top.a + under.a * (1 - top.a);
|
||||
if (a === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
const mix = (t: number, u: number) =>
|
||||
(t * top.a + u * under.a * (1 - top.a)) / a;
|
||||
return {
|
||||
r: mix(top.r, under.r),
|
||||
g: mix(top.g, under.g),
|
||||
b: mix(top.b, under.b),
|
||||
a,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes `color-mix(in srgb, …)` from its arguments, the two colors with
|
||||
* their optional percentages, as CSS does: premultiplied, and scaled down
|
||||
* when the percentages sum under 100%.
|
||||
*/
|
||||
function colorMix(args: string[]): string {
|
||||
const [space, ...colors] = args;
|
||||
if (space !== "in srgb" || colors.length !== 2) {
|
||||
throw new Error(`Unsupported color-mix: ${args.join(", ")}`);
|
||||
}
|
||||
const parsed = colors.map((arg) => {
|
||||
const pct = arg.match(/\s([\d.]+)%$/);
|
||||
return {
|
||||
color: parseColor(pct ? arg.slice(0, pct.index) : arg),
|
||||
pct: pct ? parseFloat(pct[1]) / 100 : null,
|
||||
};
|
||||
});
|
||||
const [p1, p2] = [
|
||||
parsed[0].pct ?? 1 - (parsed[1].pct ?? 0.5),
|
||||
parsed[1].pct ?? 1 - (parsed[0].pct ?? 0.5),
|
||||
];
|
||||
const sum = p1 + p2;
|
||||
const [w1, w2] = [p1 / sum, p2 / sum];
|
||||
const [c1, c2] = [parsed[0].color, parsed[1].color];
|
||||
const a = c1.a * w1 + c2.a * w2;
|
||||
const mix = (x: number, y: number) =>
|
||||
a === 0 ? 0 : (x * c1.a * w1 + y * c2.a * w2) / a;
|
||||
return formatColor({
|
||||
r: mix(c1.r, c2.r),
|
||||
g: mix(c1.g, c2.g),
|
||||
b: mix(c1.b, c2.b),
|
||||
a: a * Math.min(sum, 1),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tokens of `style` in `scheme`, read from the site's stylesheets
|
||||
* at the time of the call. `styles.ts` reads each style once, at load.
|
||||
*/
|
||||
export function readTokens(style: StyleName, scheme: Scheme): ThemeTokens {
|
||||
const styleSel = style === BASE_STYLE
|
||||
? `(?:\\[data-style="${style}"\\]|:not\\(\\[data-style\\]\\))`
|
||||
: `\\[data-style="${style}"\\]`;
|
||||
const rootRule = new RegExp(
|
||||
`^(?::root)?(?:${styleSel})?(?:\\[data-color-scheme="${scheme}"\\])?$`,
|
||||
);
|
||||
|
||||
// Later declarations win, as in the cascade: the base tokens, then the
|
||||
// theme's, then its forced scheme's.
|
||||
const declared = new Map<string, string>();
|
||||
for (const source of SOURCES) {
|
||||
const css = stripComments(Deno.readTextFileSync(
|
||||
new URL(source.replace("{style}", style), import.meta.url),
|
||||
));
|
||||
for (const { selector, body } of topRules(css)) {
|
||||
if (selector.startsWith("@")) continue;
|
||||
if (!selector.split(",").some((part) => rootRule.test(part.trim()))) {
|
||||
continue;
|
||||
}
|
||||
for (const declaration of splitTop(body, ";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
const name = declaration.slice(0, colon).trim();
|
||||
if (!name.startsWith("--")) continue;
|
||||
declared.set(
|
||||
name,
|
||||
declaration.slice(colon + 1).replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = new Map<string, string>();
|
||||
const value = (token: string): string => {
|
||||
const known = resolved.get(token);
|
||||
if (known !== undefined) return known;
|
||||
const raw = declared.get(token);
|
||||
if (raw === undefined) throw new Error(`${style} sets no ${token}`);
|
||||
let result = replaceCalls(
|
||||
raw,
|
||||
"var",
|
||||
([name, fallback]) =>
|
||||
declared.has(name) ? value(name) : fallback ?? value(name),
|
||||
);
|
||||
result = replaceCalls(
|
||||
result,
|
||||
"light-dark",
|
||||
([light, dark]) => scheme === "light" ? light : dark,
|
||||
);
|
||||
result = replaceCalls(result, "color-mix", colorMix);
|
||||
resolved.set(token, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
value,
|
||||
color(token, under) {
|
||||
const color = parseColor(value(token));
|
||||
return formatColor(under ? over(color, parseColor(value(under))) : color);
|
||||
},
|
||||
stops(token) {
|
||||
const gradient = value(token).match(/^linear-gradient\((.*)\)$/)?.[1];
|
||||
if (!gradient) throw new Error(`${style} ${token} is no linear gradient`);
|
||||
return splitTop(gradient, ",")
|
||||
.filter((stop) => !/^(?:to\s|[\d.-]+(?:deg|turn|rad)$)/.test(stop))
|
||||
.map((stop) => formatColor(parseColor(splitTop(stop, " ")[0])));
|
||||
},
|
||||
font(token) {
|
||||
return splitTop(value(token), ",")
|
||||
.map((family) => family.replace(/["']/g, "").replace(/ Variable$/, ""))
|
||||
.map((family) => /^[\w-]+$/.test(family) ? family : `'${family}'`)
|
||||
.join(", ");
|
||||
},
|
||||
px(token) {
|
||||
const length = value(token).match(/^([\d.]+)(?:px)?$/)?.[1];
|
||||
if (length === undefined) {
|
||||
throw new Error(`${style} ${token} is no pixel length`);
|
||||
}
|
||||
return parseFloat(length);
|
||||
},
|
||||
};
|
||||
}
|
||||
80
api/mail/welcome.ts
Normal file
80
api/mail/welcome.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Renders the email that greets a new account. `sendWelcomeEmail`
|
||||
* (`user-service.ts`) sends it after sign-up, in the instance's style and
|
||||
* language. It opens with the instance's `GERBEUR_WELCOME_EMAIL_BODY`, or the
|
||||
* built-in body, then says the digests are on, since they are by default, and
|
||||
* where to turn them off.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { FRONTEND_URL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
|
||||
import { i18n } from "./i18n.ts";
|
||||
import {
|
||||
EmailKit,
|
||||
escMd,
|
||||
lookOf,
|
||||
mdLink,
|
||||
type RenderedEmail,
|
||||
} from "./layout.ts";
|
||||
|
||||
/**
|
||||
* Returns the welcome email of `username`. The instance's body has
|
||||
* `{{username}}` and `{{site_name}}` filled in; raw HTML in it passes
|
||||
* through, since it comes from whoever runs the instance, never from a user.
|
||||
*/
|
||||
export function renderWelcomeEmail(username: string): RenderedEmail {
|
||||
// A username may hold a `_`, which Markdown would read as emphasis.
|
||||
const body = WELCOME_EMAIL_BODY
|
||||
? WELCOME_EMAIL_BODY
|
||||
.replaceAll("{{username}}", escMd(username))
|
||||
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
|
||||
: i18n._({
|
||||
id: "email.welcome.body",
|
||||
message:
|
||||
"# Welcome to {site}!\n\nHi **{username}**,\n\nYour account has been created successfully. Welcome aboard!",
|
||||
values: { site: escMd(OG_SITE_NAME), username: escMd(username) },
|
||||
});
|
||||
const markdown = [
|
||||
body,
|
||||
mdLink(
|
||||
i18n._({
|
||||
id: "email.welcome.button",
|
||||
message: "Open {site}",
|
||||
values: { site: OG_SITE_NAME },
|
||||
}),
|
||||
FRONTEND_URL,
|
||||
),
|
||||
`*${
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.welcome.digests",
|
||||
message:
|
||||
"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.",
|
||||
}),
|
||||
)
|
||||
}*`,
|
||||
"---",
|
||||
escMd(
|
||||
i18n._({
|
||||
id: "email.welcome.reason",
|
||||
message:
|
||||
"You're getting this email because an account was created on {site} with this address.",
|
||||
values: { site: OG_SITE_NAME },
|
||||
}),
|
||||
),
|
||||
].join("\n\n");
|
||||
|
||||
return new EmailKit(lookOf()).render({
|
||||
subject: i18n._({
|
||||
id: "email.welcome.subject",
|
||||
message: "Welcome to {site}",
|
||||
values: { site: OG_SITE_NAME },
|
||||
}),
|
||||
preheader: i18n._({
|
||||
id: "email.welcome.preheader",
|
||||
message: "Your account is ready.",
|
||||
}),
|
||||
markdown,
|
||||
});
|
||||
}
|
||||
181
api/main.ts
181
api/main.ts
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
* Builds the Oak application and, when run directly, starts the recurring
|
||||
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
|
||||
* the users out of other instances if they may no longer sign in there
|
||||
* (`FEDERATION.outgoing`). A request goes
|
||||
* through the error middleware, the compression of what is answered, the OG
|
||||
* tags and CORS, then the routers, one
|
||||
* per resource of the API (`API.md` lists them), and last the built frontend,
|
||||
* which answers every path no router did.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,28 +15,52 @@ import { Application } from "@oak/oak";
|
||||
import { oakCors } from "@tajpouria/cors";
|
||||
|
||||
import attachmentsRouter from "./routes/attachments.ts";
|
||||
import avatarsRouter from "./routes/avatars.ts";
|
||||
import categoriesRouter from "./routes/categories.ts";
|
||||
import chatRouter from "./routes/chat.ts";
|
||||
import communityRouter from "./routes/community.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import dumpsRouter from "./routes/dumps.ts";
|
||||
import filesRouter from "./routes/files.ts";
|
||||
import thumbnailsRouter from "./routes/thumbnails.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
import avatarsRouter from "./routes/avatars.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
import bandcampRouter from "./routes/bandcamp.ts";
|
||||
import youtubeRouter from "./routes/youtube.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import chatRouter from "./routes/chat.ts";
|
||||
import federationRouter from "./routes/federation.ts";
|
||||
import followsRouter from "./routes/follows.ts";
|
||||
import notificationsRouter from "./routes/notifications.ts";
|
||||
import invitesRouter from "./routes/invites.ts";
|
||||
import meRouter from "./routes/me.ts";
|
||||
import notificationsRouter from "./routes/notifications.ts";
|
||||
import oauthRouter from "./routes/oauth.ts";
|
||||
import passwordResetsRouter from "./routes/password-resets.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import remoteRouter from "./routes/remote.ts";
|
||||
import searchRouter from "./routes/search.ts";
|
||||
import categoriesRouter from "./routes/categories.ts";
|
||||
import sessionsRouter from "./routes/sessions.ts";
|
||||
import thumbnailsRouter from "./routes/thumbnails.ts";
|
||||
import waveformsRouter from "./routes/waveforms.ts";
|
||||
import unsubscribeRouter from "./routes/unsubscribe.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
import wellKnownRouter from "./routes/well-known.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
|
||||
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
|
||||
import {
|
||||
ALLOWED_ORIGINS,
|
||||
FEDERATION,
|
||||
LISTEN_HOST,
|
||||
PORT,
|
||||
PUBLIC_URL,
|
||||
} from "./config.ts";
|
||||
import { compressMiddleware } from "./middleware/compress.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
import { ogMiddleware } from "./middleware/og.ts";
|
||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||
import { startJobs } from "./lib/scheduler.ts";
|
||||
import { rateLimitSweepJob } from "./lib/rate-limit.ts";
|
||||
import { generatedMediaSweepJob } from "./lib/ffmpeg.ts";
|
||||
import { digestJob } from "./services/digest-service.ts";
|
||||
import { communityJob } from "./services/community-service.ts";
|
||||
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
|
||||
import {
|
||||
outboxJob,
|
||||
withdrawAllGrants,
|
||||
} from "./services/identity-provider-service.ts";
|
||||
|
||||
/**
|
||||
* The whole server, exported so the tests' helpers can send it requests
|
||||
@@ -43,6 +71,7 @@ const app: Application = new Application();
|
||||
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
||||
|
||||
app.use(errorMiddleware);
|
||||
app.use(compressMiddleware);
|
||||
app.use(ogMiddleware);
|
||||
// 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
|
||||
@@ -54,78 +83,37 @@ app.use(async (ctx, next) => {
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.use(
|
||||
dumpsRouter.routes(),
|
||||
dumpsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
filesRouter.routes(),
|
||||
filesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
thumbnailsRouter.routes(),
|
||||
thumbnailsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
attachmentsRouter.routes(),
|
||||
attachmentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
usersRouter.routes(),
|
||||
usersRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
avatarsRouter.routes(),
|
||||
avatarsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
wsRouter.routes(),
|
||||
wsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
previewRouter.routes(),
|
||||
previewRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
bandcampRouter.routes(),
|
||||
bandcampRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
youtubeRouter.routes(),
|
||||
youtubeRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
playlistsRouter.routes(),
|
||||
playlistsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
commentsRouter.routes(),
|
||||
commentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
chatRouter.routes(),
|
||||
chatRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
followsRouter.routes(),
|
||||
followsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
notificationsRouter.routes(),
|
||||
notificationsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
invitesRouter.routes(),
|
||||
invitesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
searchRouter.routes(),
|
||||
searchRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
categoriesRouter.routes(),
|
||||
categoriesRouter.allowedMethods(),
|
||||
);
|
||||
for (
|
||||
const router of [
|
||||
meRouter,
|
||||
followsRouter,
|
||||
notificationsRouter,
|
||||
avatarsRouter,
|
||||
usersRouter,
|
||||
sessionsRouter,
|
||||
passwordResetsRouter,
|
||||
unsubscribeRouter,
|
||||
dumpsRouter,
|
||||
filesRouter,
|
||||
thumbnailsRouter,
|
||||
waveformsRouter,
|
||||
commentsRouter,
|
||||
playlistsRouter,
|
||||
categoriesRouter,
|
||||
searchRouter,
|
||||
chatRouter,
|
||||
communityRouter,
|
||||
invitesRouter,
|
||||
attachmentsRouter,
|
||||
remoteRouter,
|
||||
federationRouter,
|
||||
oauthRouter,
|
||||
wellKnownRouter,
|
||||
wsRouter,
|
||||
]
|
||||
) {
|
||||
app.use(router.routes(), router.allowedMethods());
|
||||
}
|
||||
app.use(routeStaticFilesFrom([
|
||||
`${Deno.cwd()}/dist`,
|
||||
`${Deno.cwd()}/public`,
|
||||
@@ -142,6 +130,21 @@ app.addEventListener(
|
||||
);
|
||||
|
||||
if (import.meta.main) {
|
||||
if (!FEDERATION.outgoing) {
|
||||
const withdrawn = await withdrawAllGrants();
|
||||
if (withdrawn > 0) {
|
||||
console.log(`Signed users out of other instances (${withdrawn} grants)`);
|
||||
}
|
||||
}
|
||||
startJobs([
|
||||
digestJob,
|
||||
communityJob,
|
||||
pingJob,
|
||||
typingExpiryJob,
|
||||
rateLimitSweepJob,
|
||||
generatedMediaSweepJob,
|
||||
outboxJob,
|
||||
]);
|
||||
await app.listen({ hostname: LISTEN_HOST, port: PORT });
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface AuthState extends State {
|
||||
|
||||
/**
|
||||
* The context type of a handler behind {@link authMiddleware}. Pass the route
|
||||
* path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed.
|
||||
* path, as in `AuthContext<"/:key/role">`, to keep `ctx.params` typed.
|
||||
*/
|
||||
export type AuthContext<
|
||||
R extends string = string,
|
||||
|
||||
108
api/middleware/compress.ts
Normal file
108
api/middleware/compress.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Compresses what the routes answer in JSON, and the HTML page, for the
|
||||
* browsers that accept it: a page of the feed is some twenty kilobytes as it
|
||||
* is, and a fifth of that compressed. main.ts installs it after the error
|
||||
* middleware, around everything that sets a body. The built frontend's files
|
||||
* aren't its concern: lib/static.ts sends the copies compressed at build time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context, Next } from "@oak/oak";
|
||||
import { promisify } from "node:util";
|
||||
import { brotliCompress, constants as zlib, gzip } from "node:zlib";
|
||||
|
||||
const brotli = promisify(brotliCompress);
|
||||
const gz = promisify(gzip);
|
||||
|
||||
/**
|
||||
* Below this, the bytes saved aren't worth the work; it is the threshold the
|
||||
* build compresses the frontend's files from (vite.config.ts).
|
||||
*/
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* Brotli's quality for a response made on the spot. Past 5 it takes several
|
||||
* times longer for a few percent; the frontend's files, compressed once, are
|
||||
* at 11.
|
||||
*/
|
||||
const BROTLI_QUALITY = 5;
|
||||
|
||||
/**
|
||||
* Returns the coding to answer with for an `Accept-Encoding` header, brotli
|
||||
* before gzip, or null when the client takes neither. A coding refused with
|
||||
* `q=0` isn't taken.
|
||||
*/
|
||||
function pickCoding(header: string | null): "br" | "gzip" | null {
|
||||
if (!header) return null;
|
||||
const accepted = new Set<string>();
|
||||
for (const part of header.split(",")) {
|
||||
const [name, ...params] = part.trim().toLowerCase().split(";");
|
||||
const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1])
|
||||
.find((v) => v !== undefined);
|
||||
if (q === undefined || Number(q) > 0) accepted.add(name.trim());
|
||||
}
|
||||
return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text of a body this middleware compresses, or null for any
|
||||
* other: an object or array, which Oak would send as JSON, or a string whose
|
||||
* type its route has set. Files, streams and bytes are left alone, as they
|
||||
* are images, media and files compressed already.
|
||||
*/
|
||||
function textOf(ctx: Context): string | null {
|
||||
const body = ctx.response.body;
|
||||
if (typeof body === "string") {
|
||||
// Oak guesses an untyped string's type from the string itself, which it
|
||||
// couldn't from its compressed bytes.
|
||||
return ctx.response.type || ctx.response.headers.has("Content-Type")
|
||||
? body
|
||||
: null;
|
||||
}
|
||||
if (body === null || typeof body !== "object") return null;
|
||||
const proto = Object.getPrototypeOf(body);
|
||||
if (proto !== Object.prototype && !Array.isArray(body)) return null;
|
||||
ctx.response.type = "json";
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the response's JSON or text compressed, in brotli or gzip as the
|
||||
* request accepts, when it is at least {@link MIN_BYTES} long. A response
|
||||
* already encoded, a `HEAD`, and one marked `no-transform` go out as they are.
|
||||
*/
|
||||
export async function compressMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
await next();
|
||||
|
||||
const headers = ctx.response.headers;
|
||||
if (
|
||||
ctx.request.method === "HEAD" || headers.has("Content-Encoding") ||
|
||||
headers.get("Cache-Control")?.includes("no-transform")
|
||||
) return;
|
||||
|
||||
const text = textOf(ctx);
|
||||
if (text === null) return;
|
||||
const raw = new TextEncoder().encode(text);
|
||||
if (raw.length < MIN_BYTES) return;
|
||||
|
||||
// A cache must not hand the compressed copy to a client that can't read it.
|
||||
headers.append("Vary", "Accept-Encoding");
|
||||
const coding = pickCoding(ctx.request.headers.get("Accept-Encoding"));
|
||||
if (!coding) return;
|
||||
|
||||
const compressed = coding === "br"
|
||||
? await brotli(raw, {
|
||||
params: {
|
||||
[zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT,
|
||||
[zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
|
||||
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
|
||||
},
|
||||
})
|
||||
: await gz(raw);
|
||||
headers.set("Content-Encoding", coding);
|
||||
ctx.response.body = new Uint8Array(compressed);
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
* Gives links to the site rich previews in chat apps and social sites, which
|
||||
* don't run the app. It 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, and a link by a former name with a 301 to the current one. main.ts
|
||||
* installs it before the routers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../services/user-service.ts";
|
||||
import { getPlaylistById } from "../services/playlist-service.ts";
|
||||
import { getDumpByPath } from "../services/dump-service.ts";
|
||||
import { resolveUsername } from "../services/user-service.ts";
|
||||
import { getPlaylistByPath } from "../services/playlist-service.ts";
|
||||
import {
|
||||
dumpPath,
|
||||
parsePagePath,
|
||||
playlistPath,
|
||||
userPath,
|
||||
} from "../../shared/paths.ts";
|
||||
import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
|
||||
import { loadIndexHtml } from "../lib/index-html.ts";
|
||||
|
||||
@@ -83,17 +90,23 @@ async function findIndexHtml(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
/** Matches a profile, and its tabs under `/~/<tab>`. */
|
||||
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
/** Answers `canonical` with a 301, keeping the request's query string. */
|
||||
function redirect(ctx: Context, canonical: string): void {
|
||||
ctx.response.status = 301;
|
||||
// Revalidated each time: a slug taken back would otherwise loop through a
|
||||
// redirect the browser kept.
|
||||
ctx.response.headers.set("Cache-Control", "no-cache");
|
||||
ctx.response.redirect(`${FRONTEND_URL}${canonical}${ctx.request.url.search}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* path names (`parsePagePath`), and passes the request on when it names none
|
||||
* that exists or the visitor may see. A path naming one by a former name, a
|
||||
* user's or a slug, or in another case, gets a 301 to its current path. 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,25 +114,25 @@ export async function ogMiddleware(
|
||||
): Promise<unknown> {
|
||||
const { pathname } = ctx.request.url;
|
||||
|
||||
if (pathname.startsWith("/api/") || pathname.includes(".")) {
|
||||
return await next();
|
||||
}
|
||||
// A file's path never starts with `@`, so parsePagePath passes it on, while
|
||||
// a remote user's has dots in it: `/@bob@music.example`.
|
||||
if (pathname.startsWith("/api/")) return await next();
|
||||
|
||||
const page = parsePagePath(pathname);
|
||||
if (!page || (page.kind !== "user" && page.edit)) return await next();
|
||||
|
||||
const origin = PUBLIC_URL;
|
||||
const pageUrl = `${FRONTEND_URL}${pathname}`;
|
||||
|
||||
let meta: OGMeta | null = null;
|
||||
let canonical: string | null = null;
|
||||
|
||||
const dumpMatch = pathname.match(DUMP_RE);
|
||||
const userMatch = pathname.match(USER_RE);
|
||||
const playlistMatch = pathname.match(PLAYLIST_RE);
|
||||
|
||||
if (dumpMatch) {
|
||||
try {
|
||||
const dump = getDump(dumpMatch[1]);
|
||||
try {
|
||||
const user = resolveUsername(page.username);
|
||||
if (page.kind === "dump") {
|
||||
const dump = getDumpByPath(user.id, page.slug);
|
||||
canonical = dumpPath(dump);
|
||||
let imageUrl: string | undefined;
|
||||
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) {
|
||||
imageUrl = `${origin}/api/files/${dump.id}`;
|
||||
imageUrl = `${origin}/api/dumps/${dump.id}/file`;
|
||||
} else if (dump.richContent?.thumbnailUrl) {
|
||||
imageUrl = dump.richContent.thumbnailUrl;
|
||||
}
|
||||
@@ -130,41 +143,41 @@ export async function ogMiddleware(
|
||||
description: dump.comment,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
imageIsWide: !!imageUrl,
|
||||
url: pageUrl,
|
||||
url: `${FRONTEND_URL}${canonical}`,
|
||||
};
|
||||
} catch { /* Unknown, or private. */ }
|
||||
} else if (userMatch) {
|
||||
try {
|
||||
const user = getUserByUsername(userMatch[1]);
|
||||
} else if (page.kind === "user") {
|
||||
canonical = [userPath(user.username), ...page.rest].join("/");
|
||||
const imageUrl = user.avatarMime
|
||||
? `${origin}/api/avatars/${user.id}`
|
||||
? `${origin}/api/users/${user.id}/avatar`
|
||||
: undefined;
|
||||
meta = {
|
||||
title: user.username,
|
||||
description: user.description,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
url: pageUrl,
|
||||
url: `${FRONTEND_URL}${canonical}`,
|
||||
};
|
||||
} catch { /* Unknown. */ }
|
||||
} else if (playlistMatch) {
|
||||
try {
|
||||
const playlist = getPlaylistById(playlistMatch[1]);
|
||||
// getPlaylistById doesn't check visibility, unlike getDump.
|
||||
} else {
|
||||
const playlist = getPlaylistByPath(user.id, page.slug);
|
||||
// getPlaylistByPath doesn't check visibility, unlike getDumpByPath.
|
||||
if (playlist.isPublic) {
|
||||
canonical = playlistPath(playlist);
|
||||
const imageUrl = playlist.imageMime
|
||||
? `${origin}/api/playlists/${playlist.id}/image`
|
||||
? `${origin}/api/playlists/${playlist.id}/cover`
|
||||
: undefined;
|
||||
meta = {
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
url: pageUrl,
|
||||
url: `${FRONTEND_URL}${canonical}`,
|
||||
};
|
||||
}
|
||||
} catch { /* Unknown. */ }
|
||||
}
|
||||
}
|
||||
} catch { /* Unknown, or private. */ }
|
||||
|
||||
if (!meta) return await next();
|
||||
if (!meta || !canonical) return await next();
|
||||
if (canonical !== pathname.replace(/\/+$/, "")) {
|
||||
return redirect(ctx, canonical);
|
||||
}
|
||||
|
||||
const html = await findIndexHtml();
|
||||
if (!html) return await next();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Checks what a verified token says before it is trusted. It types the payloads
|
||||
* of session and password reset tokens, and `lib/jwt.ts` runs their guards when
|
||||
* it verifies one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Types the rows the category service reads from `categories`, and turns them
|
||||
* into the {@link Category} the category routes send.
|
||||
* Turns the rows the category service reads from `categories` into the {@link
|
||||
* Category} the category routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import {
|
||||
type Category,
|
||||
type CategoryKind,
|
||||
isCategoryKind,
|
||||
} from "../../shared/category.ts";
|
||||
|
||||
/** Returned by the category service's `SELECT`s on `categories`. */
|
||||
export interface CategoryRow {
|
||||
id: string;
|
||||
kind: CategoryKind;
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Sorts the categories, lowest first. */
|
||||
@@ -24,6 +29,7 @@ export interface CategoryRow {
|
||||
export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"kind" in obj && isCategoryKind(obj.kind) &&
|
||||
"slug" in obj && typeof obj.slug === "string" &&
|
||||
"name" in obj && typeof obj.name === "string" &&
|
||||
"position" in obj && typeof obj.position === "number" &&
|
||||
@@ -36,6 +42,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
export function categoryRowToApi(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends.
|
||||
* Turns the rows of `chat_messages` into the {@link ChatMessage} the API sends.
|
||||
* Only `services/chat-service.ts` uses it, on the rows of its one query.
|
||||
*
|
||||
* @module
|
||||
@@ -26,6 +26,8 @@ export interface ChatMessageRow {
|
||||
reply_to_author: string | null;
|
||||
/** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */
|
||||
reply_to_body: string | null;
|
||||
/** 1 or 0. */
|
||||
mentions_everyone: number;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
@@ -50,7 +52,8 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
"reply_to_author" in obj &&
|
||||
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
|
||||
"reply_to_body" in obj &&
|
||||
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
|
||||
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null) &&
|
||||
"mentions_everyone" in obj && typeof obj.mentions_everyone === "number";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,5 +72,6 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||
replyToId: row.reply_to_id ?? undefined,
|
||||
replyToAuthor: row.reply_to_author ?? undefined,
|
||||
replyToBody: row.reply_to_body ?? undefined,
|
||||
mentionsEveryone: row.mentions_everyone === 1 || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types the rows the comment service reads from `comments`, and turns them into
|
||||
* the {@link Comment} the comment routes send.
|
||||
* Turns the rows the comment service reads from `comments` into the {@link
|
||||
* Comment} the comment routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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}.
|
||||
* Turns the rows of `dumps` into the API's {@link Dump} the same way for every
|
||||
* service that reads dumps: each selects them with the column lists here,
|
||||
* checks each row with {@link isDumpRow} and converts it with {@link
|
||||
* dumpRowToApi}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -22,7 +23,8 @@ export interface DumpRow {
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
slug: string | null;
|
||||
/** Unique among its poster's dumps, and set on every row since 0021. */
|
||||
slug: string;
|
||||
/** The link as the poster submitted it. */
|
||||
url: string | null;
|
||||
/** {@link RichContent} as JSON. */
|
||||
@@ -33,6 +35,8 @@ export interface DumpRow {
|
||||
vote_count: number;
|
||||
/** Computed by a subquery, which counts the comments that aren't deleted. */
|
||||
comment_count: number;
|
||||
/** The poster's current name, computed by a subquery. */
|
||||
owner_username: string;
|
||||
is_private: number;
|
||||
custom_thumbnail_mime: string | null;
|
||||
/** Lets a query select more columns, such as a join's. */
|
||||
@@ -52,17 +56,19 @@ const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`)
|
||||
|
||||
/**
|
||||
* Selects a whole {@link DumpRow}, the stored columns and the computed
|
||||
* `comment_count`.
|
||||
* `comment_count` and `owner_username`.
|
||||
*/
|
||||
export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count,
|
||||
(SELECT username FROM users WHERE id = dumps.user_id) as owner_username`;
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count,
|
||||
(SELECT username FROM users WHERE id = d.user_id) as owner_username`;
|
||||
|
||||
/**
|
||||
* Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
|
||||
@@ -80,7 +86,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||
"updated_at" in obj &&
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
||||
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
|
||||
"slug" in obj && typeof obj.slug === "string" &&
|
||||
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
|
||||
"rich_content" in obj &&
|
||||
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
|
||||
@@ -92,6 +98,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
||||
"vote_count" in obj && typeof obj.vote_count === "number" &&
|
||||
"comment_count" in obj && typeof obj.comment_count === "number" &&
|
||||
"owner_username" in obj && typeof obj.owner_username === "string" &&
|
||||
"is_private" in obj && typeof obj.is_private === "number" &&
|
||||
"custom_thumbnail_mime" in obj &&
|
||||
(typeof obj.custom_thumbnail_mime === "string" ||
|
||||
@@ -107,10 +114,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
id: row.id,
|
||||
kind: row.kind as "url" | "file",
|
||||
title: row.title,
|
||||
slug: row.slug ?? undefined,
|
||||
slug: row.slug,
|
||||
url: row.url ?? undefined,
|
||||
comment: row.comment ?? undefined,
|
||||
userId: row.user_id,
|
||||
ownerUsername: row.owner_username,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
richContent: row.rich_content
|
||||
@@ -135,7 +143,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
id: dump.id,
|
||||
kind: dump.kind,
|
||||
title: dump.title,
|
||||
slug: dump.slug ?? null,
|
||||
slug: dump.slug,
|
||||
comment: dump.comment ?? null,
|
||||
user_id: dump.userId,
|
||||
created_at: dump.createdAt.toISOString(),
|
||||
@@ -147,6 +155,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
file_size: dump.fileSize ?? null,
|
||||
vote_count: dump.voteCount,
|
||||
comment_count: dump.commentCount,
|
||||
owner_username: dump.ownerUsername,
|
||||
is_private: dump.isPrivate ? 1 : 0,
|
||||
custom_thumbnail_mime: dump.thumbnailMime ?? null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types and guards the rows of `follows`, which `follow-service.ts` reads to
|
||||
* answer what a user follows.
|
||||
* Checks the rows `follow-service.ts` reads from `follows` to answer what a
|
||||
* user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types and guards the rows of `invites`, which `invite-service.ts` reads to
|
||||
* check an invite before sign-up.
|
||||
* Checks the rows `invite-service.ts` reads from `invites` to vet an invite
|
||||
* before sign-up.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types and guards the rows of `notifications`, and converts them to the
|
||||
* {@link Notification} that `GET /api/notifications` answers.
|
||||
* Turns the rows of `notifications`, once checked, into the {@link
|
||||
* Notification} that `GET /api/me/notifications` answers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types and guards the rows of `password_reset_tokens`, which
|
||||
* `password-reset-service.ts` reads to make each reset link work once.
|
||||
* Checks the rows `password-reset-service.ts` reads from
|
||||
* `password_reset_tokens` to make each reset link work once.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Types the rows the playlist and follow services read from `playlists`, and
|
||||
* turns them into the {@link Playlist} the API sends.
|
||||
* Turns the rows the playlist and follow services read from `playlists` into
|
||||
* the {@link Playlist} the API sends.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,15 +9,18 @@ import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/**
|
||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`. A
|
||||
* query may add `dump_count` and `owner_username` columns, which
|
||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
|
||||
* which join the owner's name as `owner_username`. A query may add the
|
||||
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
|
||||
* {@link playlistRowToApi} carries over when present.
|
||||
*/
|
||||
export interface PlaylistRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
slug: string | null;
|
||||
/** Unique among its owner's playlists, and set on every row since 0021. */
|
||||
slug: string;
|
||||
owner_username: string;
|
||||
description: string | null;
|
||||
is_public: number;
|
||||
created_at: string;
|
||||
@@ -34,7 +37,8 @@ export function isPlaylistRow(
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||
"title" in obj && typeof obj.title === "string" &&
|
||||
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
|
||||
"slug" in obj && typeof obj.slug === "string" &&
|
||||
"owner_username" in obj && typeof obj.owner_username === "string" &&
|
||||
"description" in obj &&
|
||||
(typeof obj.description === "string" || obj.description === null) &&
|
||||
"is_public" in obj && typeof obj.is_public === "number" &&
|
||||
@@ -51,15 +55,16 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
title: row.title,
|
||||
slug: row.slug ?? undefined,
|
||||
slug: row.slug,
|
||||
description: row.description ?? undefined,
|
||||
isPublic: Boolean(row.is_public),
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
imageMime: row.image_mime ?? undefined,
|
||||
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
||||
ownerUsername: typeof row.owner_username === "string"
|
||||
? row.owner_username
|
||||
lastDumpAt: typeof row.last_dump_at === "string"
|
||||
? new Date(row.last_dump_at)
|
||||
: undefined,
|
||||
ownerUsername: row.owner_username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
* Makes a damaged row fail its query with a 500 rather than silently vanish
|
||||
* from a list. Every service checks the rows of its list queries with it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
66
api/model/settings.ts
Normal file
66
api/model/settings.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Checks the rows `settings-service.ts` reads from `user_settings`, and turns
|
||||
* them into the {@link UserSettings} the settings endpoints answer.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { UserSettings } from "../../shared/settings.ts";
|
||||
import { isColorScheme, isStyleName } from "../../shared/theme.ts";
|
||||
|
||||
/**
|
||||
* Stored by `updateSettings` and read back by `getSettings`. A user without a
|
||||
* row has the defaults.
|
||||
*/
|
||||
export interface UserSettingsRow {
|
||||
user_id: string;
|
||||
/** A style name, or null for the instance's. */
|
||||
style: string | null;
|
||||
color_scheme: string | null;
|
||||
/** 0 or 1. */
|
||||
digest_weekly: number;
|
||||
/** 0 or 1. */
|
||||
digest_monthly: number;
|
||||
updated_at: string;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the column of each setting, for the queries that pick users by one,
|
||||
* as `digest-service.ts` does by its digest's switch.
|
||||
*/
|
||||
export const SETTINGS_COLUMNS: Record<keyof UserSettings, string> = {
|
||||
style: "style",
|
||||
colorScheme: "color_scheme",
|
||||
digestWeekly: "digest_weekly",
|
||||
digestMonthly: "digest_monthly",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether `obj`, a row `getSettings` selected, is a
|
||||
* {@link UserSettingsRow}.
|
||||
*/
|
||||
export function isUserSettingsRow(obj: unknown): obj is UserSettingsRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||
"style" in obj && (obj.style === null || typeof obj.style === "string") &&
|
||||
"color_scheme" in obj &&
|
||||
(obj.color_scheme === null || typeof obj.color_scheme === "string") &&
|
||||
"digest_weekly" in obj && typeof obj.digest_weekly === "number" &&
|
||||
"digest_monthly" in obj && typeof obj.digest_monthly === "number" &&
|
||||
"updated_at" in obj && typeof obj.updated_at === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a row to the {@link UserSettings} the client gets. A style or
|
||||
* scheme the code no longer knows, as after one is removed, reads as unset.
|
||||
*/
|
||||
export function settingsRowToApi(row: UserSettingsRow): UserSettings {
|
||||
return {
|
||||
style: isStyleName(row.style) ? row.style : null,
|
||||
colorScheme: isColorScheme(row.color_scheme) ? row.color_scheme : null,
|
||||
digestWeekly: row.digest_weekly === 1,
|
||||
digestMonthly: row.digest_monthly === 1,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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}.
|
||||
* Turns the rows of `users`, once checked, into 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
|
||||
*/
|
||||
@@ -19,8 +19,22 @@ import {
|
||||
* it carries the password hash.
|
||||
*/
|
||||
export interface User extends SelfUser {
|
||||
/** From `hashPassword`, as `hash.salt`. */
|
||||
passwordHash: string;
|
||||
/**
|
||||
* From `hashPassword`, as `hash.salt`, or null for an account from another
|
||||
* instance ({@link isRemoteUser}), which signs in there and never with a
|
||||
* password here. The row stores that as `''`.
|
||||
*/
|
||||
passwordHash: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `user`'s account lives on another instance, whose user signs
|
||||
* in here through federation (`federation-service.ts`). Their password,
|
||||
* email, avatar and name are that instance's to change, so the routes that
|
||||
* change them here refuse.
|
||||
*/
|
||||
export function isRemoteUser(user: User): boolean {
|
||||
return user.passwordHash === null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +63,7 @@ export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
/** `''` for an account from another instance, which has no password. */
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
@@ -97,7 +112,7 @@ export function userRowToApi(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
passwordHash: row.password_hash || null,
|
||||
role: row.role,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
@@ -118,7 +133,7 @@ export function userApiToRow(user: User): UserRow {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
password_hash: user.passwordHash,
|
||||
password_hash: user.passwordHash ?? "",
|
||||
role: user.role,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
updated_at: user.updatedAt?.toISOString() ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
* starts it, for `GET /api/remote/bandcamp`. 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.
|
||||
@@ -9,19 +9,16 @@
|
||||
*/
|
||||
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { TtlCache } from "../lib/ttl-cache.ts";
|
||||
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
|
||||
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* Half the signatures' life, so a cached URL still has at least 12 hours left
|
||||
* when it is handed out.
|
||||
* Lives 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 oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
const cache = new Map<string, Tralbum>();
|
||||
const cache = new TtlCache<Tralbum>(12 * 3_600_000, 200);
|
||||
|
||||
/**
|
||||
* Returns `url` without its query, fragment and trailing slashes, and with its
|
||||
@@ -38,25 +35,6 @@ function cacheKey(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function readCache(key: string): Tralbum | null {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.resolvedAt > TTL_MS) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
function writeCache(key: string, value: Tralbum): void {
|
||||
// Map preserves insertion order, so the first key is the oldest write.
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -87,7 +65,7 @@ export async function resolveBandcamp(
|
||||
|
||||
const key = cacheKey(pageUrl);
|
||||
if (!force) {
|
||||
const hit = readCache(key);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
@@ -115,6 +93,5 @@ export async function resolveBandcamp(
|
||||
throw new APIException(404, "No streamable tracks on this page");
|
||||
}
|
||||
|
||||
writeCache(key, parsed);
|
||||
return parsed;
|
||||
return cache.set(key, parsed);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
|
||||
import { decodeHtmlEntities } from "./html.ts";
|
||||
|
||||
/**
|
||||
* Matches the hostnames `bandcamp-streams.ts` accepts: bandcamp.com and its
|
||||
@@ -16,30 +17,6 @@ import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
|
||||
*/
|
||||
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
|
||||
|
||||
/**
|
||||
* 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> = {
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
nbsp: " ",
|
||||
};
|
||||
return input.replace(
|
||||
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g,
|
||||
(match, dec: string, hex: string, name: string) => {
|
||||
if (dec) return String.fromCodePoint(Number(dec));
|
||||
if (hex) return String.fromCodePoint(parseInt(hex, 16));
|
||||
return named[name.toLowerCase()] ?? match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -60,7 +37,7 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
|
||||
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(decodeEntitiesOnce(match[2]));
|
||||
raw = JSON.parse(decodeHtmlEntities(match[2]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts";
|
||||
import {
|
||||
bareHostname,
|
||||
isPrivateHostname,
|
||||
resolvePublicAddress,
|
||||
} from "./public-address.ts";
|
||||
import { isSelfUrl } from "../lib/self-url.ts";
|
||||
|
||||
const FETCH_HEADERS = {
|
||||
@@ -43,7 +47,7 @@ async function fetchViaCurl(
|
||||
"--proto",
|
||||
"=http,https",
|
||||
"--resolve",
|
||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||
`${bareHostname(url)}:${port}:${pinned}`,
|
||||
"--max-time",
|
||||
String(Math.ceil(timeoutMs / 1000)),
|
||||
// curl fails on a larger file rather than stopping at a cap, so this is
|
||||
@@ -171,7 +175,7 @@ function declaredCharset(contentType: string, bytes: Uint8Array): string {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* declares; the rest is cancelled, never downloaded. {@link fetchPage} and the
|
||||
* Bandcamp and YouTube resolvers read pages through it.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
@@ -183,7 +187,7 @@ export async function readTextCapped(
|
||||
try {
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch {
|
||||
// An unknown label: TextDecoder throws rather than guess.
|
||||
// TextDecoder throws on a label it doesn't know rather than guess.
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +235,33 @@ async function fetchOnce(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `url` as {@link fetchWithTimeout} does, and also returns the URL the
|
||||
* last hop answered from, which {@link fetchPage} resolves the page's links
|
||||
* against.
|
||||
*/
|
||||
async function fetchFollowing(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ res: Response; url: URL }> {
|
||||
let current = new URL(url);
|
||||
for (let hop = 0;; hop++) {
|
||||
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||
throw new TypeError(`Unsupported protocol ${current.protocol}`);
|
||||
}
|
||||
const address = await resolvePublicAddress(current);
|
||||
const res = await fetchOnce(current, address, timeoutMs);
|
||||
const location = res.headers.get("location");
|
||||
if (res.status < 300 || res.status >= 400 || !location) {
|
||||
return { res, url: current };
|
||||
}
|
||||
|
||||
await res.body?.cancel();
|
||||
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
|
||||
current = new URL(location, current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -245,20 +276,41 @@ export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Response> {
|
||||
let current = new URL(url);
|
||||
for (let hop = 0;; hop++) {
|
||||
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||
throw new TypeError(`Unsupported protocol ${current.protocol}`);
|
||||
}
|
||||
const address = await resolvePublicAddress(current);
|
||||
const res = await fetchOnce(current, address, timeoutMs);
|
||||
const location = res.headers.get("location");
|
||||
if (res.status < 300 || res.status >= 400 || !location) return res;
|
||||
return (await fetchFollowing(url, timeoutMs)).res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by {@link fetchPage} to the link preview providers.
|
||||
*/
|
||||
export interface FetchedPage {
|
||||
/**
|
||||
* The URL the page was served from, after redirects: the base its relative
|
||||
* links resolve against.
|
||||
*/
|
||||
url: string;
|
||||
/** The page's text, up to {@link MAX_PAGE_BYTES}; missing when it isn't HTML. */
|
||||
html?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a page for its link preview, through {@link fetchWithTimeout}. A
|
||||
* response that isn't HTML is cancelled unread.
|
||||
*
|
||||
* @throws {Error} When the page answers with an error status, so that an error
|
||||
* page never becomes a preview; and as {@link fetchWithTimeout} does.
|
||||
*/
|
||||
export async function fetchPage(url: string): Promise<FetchedPage> {
|
||||
const { res, url: finalUrl } = await fetchFollowing(url, 5000);
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
|
||||
current = new URL(location, current);
|
||||
throw new Error(`${finalUrl} answered ${res.status}`);
|
||||
}
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!/^(text\/html|application\/xhtml\+xml)\b/i.test(contentType)) {
|
||||
await res.body?.cancel();
|
||||
return { url: finalUrl.href };
|
||||
}
|
||||
return { url: finalUrl.href, html: await readTextCapped(res) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
271
api/remote/gerbeur-instance.ts
Normal file
271
api/remote/gerbeur-instance.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Talks to other gerbeur instances for federated sign-in: it reads their
|
||||
* metadata and public keys, trades a code for an identity token, delivers
|
||||
* events, and fetches a user's avatar. `federation-service.ts`,
|
||||
* `identity-provider-service.ts` and `federation-peer-service.ts` reach other
|
||||
* instances only through it.
|
||||
*
|
||||
* An identity is only as safe as the connection it came over, so unlike
|
||||
* `fetch.ts` it never falls back to skipping certificate checks, and follows
|
||||
* no redirect. It reaches only https instances on public addresses, unless
|
||||
* `GERBEUR_FEDERATION_ALLOW_PRIVATE` lets two instances on one machine talk.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { JSONWebKeySet } from "@panva/jose";
|
||||
import {
|
||||
type InstanceMetadata,
|
||||
isInstanceMetadata,
|
||||
} from "../../shared/federation.ts";
|
||||
import {
|
||||
FEDERATION_ALLOW_PRIVATE,
|
||||
MAX_IMAGE_SIZE_BYTES,
|
||||
OG_SITE_NAME,
|
||||
} from "../config.ts";
|
||||
import { resolvePublicAddress } from "./public-address.ts";
|
||||
import { readBytesCapped, readTextCapped } from "./fetch.ts";
|
||||
import { TtlCache } from "../lib/ttl-cache.ts";
|
||||
|
||||
/** How long a request to another instance may take, body included. */
|
||||
const TIMEOUT_MS = 8000;
|
||||
|
||||
/** Caps a metadata document, a key set or a token response. */
|
||||
const MAX_JSON_BYTES = 64 * 1024;
|
||||
|
||||
const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`;
|
||||
|
||||
/**
|
||||
* Keeps each instance's metadata and keys for 10 minutes, so that a sign-in
|
||||
* or an event doesn't fetch them each time. A key set is refetched sooner
|
||||
* when a token names a key it lacks ({@link fetchKeys} with `fresh`), at most
|
||||
* once a minute: anyone can send a token naming a key that doesn't exist.
|
||||
*/
|
||||
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
|
||||
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
|
||||
const keysRefetched = new TtlCache<true>(60_000, 500);
|
||||
/** Keeps which issuer an instance's pages belong to ({@link resolveIssuer}). */
|
||||
const issuerCache = new TtlCache<string>(10 * 60_000, 500);
|
||||
|
||||
/**
|
||||
* Checks that `url` may be reached: https on a public address, or anything
|
||||
* under `GERBEUR_FEDERATION_ALLOW_PRIVATE`.
|
||||
*
|
||||
* @throws {Error} When it may not.
|
||||
*/
|
||||
export async function assertReachable(url: URL): Promise<void> {
|
||||
if (FEDERATION_ALLOW_PRIVATE) {
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error(`Unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(`Refusing to reach ${url.origin} without https`);
|
||||
}
|
||||
await resolvePublicAddress(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one request to another instance, after {@link assertReachable}, with
|
||||
* the deadline covering the body. The caller reads or cancels the body.
|
||||
*/
|
||||
async function request(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const target = new URL(url);
|
||||
await assertReachable(target);
|
||||
return await fetch(target, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JSON body of `res`, up to {@link MAX_JSON_BYTES}.
|
||||
*
|
||||
* @throws {Error} When the status isn't a success or the body isn't JSON.
|
||||
*/
|
||||
async function readJson(res: Response, what: string): Promise<unknown> {
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new Error(`${what} answered ${res.status}`);
|
||||
}
|
||||
return JSON.parse(await readTextCapped(res, MAX_JSON_BYTES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata `origin` serves at `/.well-known/openid-configuration`,
|
||||
* whichever issuer it names.
|
||||
*
|
||||
* @throws {Error} When it can't be reached or isn't a gerbeur instance's.
|
||||
*/
|
||||
async function metadataAt(origin: string): Promise<InstanceMetadata> {
|
||||
const body = await readJson(
|
||||
await request(`${origin}/.well-known/openid-configuration`),
|
||||
origin,
|
||||
);
|
||||
if (!isInstanceMetadata(body)) {
|
||||
throw new Error(`${origin} is not a gerbeur instance this one can trust`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata of the instance at `origin`, its issuer, cached. It
|
||||
* must name `origin` as its issuer and speak this protocol's version.
|
||||
*
|
||||
* @throws {Error} When it can't be reached or isn't a gerbeur instance.
|
||||
*/
|
||||
export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
|
||||
const hit = metadataCache.get(origin);
|
||||
if (hit) return hit;
|
||||
const body = await metadataAt(origin);
|
||||
if (body.issuer !== origin) {
|
||||
throw new Error(`${origin} names itself ${body.issuer}`);
|
||||
}
|
||||
return metadataCache.set(origin, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issuer of the instance at `origin`, cached: `origin` itself, or,
|
||||
* when `origin` is where the instance's pages are and its API lives at
|
||||
* another, the issuer the pages' metadata names, whose own metadata must name
|
||||
* those pages back as where its `/authorize` is. People know an instance by
|
||||
* the address in their browser, which a sign-in form or the trust list may be
|
||||
* given; a handle and the stored identities name the issuer.
|
||||
*
|
||||
* @throws {Error} When `origin` can't be reached, isn't a gerbeur instance,
|
||||
* or names an issuer that doesn't name it back.
|
||||
*/
|
||||
export async function resolveIssuer(origin: string): Promise<string> {
|
||||
const hit = issuerCache.get(origin);
|
||||
if (hit) return hit;
|
||||
if (metadataCache.get(origin)) return origin;
|
||||
const body = await metadataAt(origin);
|
||||
if (body.issuer === origin) {
|
||||
metadataCache.set(origin, body);
|
||||
return issuerCache.set(origin, origin);
|
||||
}
|
||||
const pages =
|
||||
new URL((await fetchMetadata(body.issuer)).authorization_endpoint)
|
||||
.origin;
|
||||
if (pages !== origin) {
|
||||
throw new Error(`${origin} names ${body.issuer}, whose pages are ${pages}`);
|
||||
}
|
||||
return issuerCache.set(origin, body.issuer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public keys of the instance at `origin`, cached unless `fresh`
|
||||
* asks to refetch them and they weren't in the last minute.
|
||||
*
|
||||
* @throws {Error} When they can't be fetched.
|
||||
*/
|
||||
export async function fetchKeys(
|
||||
origin: string,
|
||||
fresh = false,
|
||||
): Promise<JSONWebKeySet> {
|
||||
const refetch = fresh && !keysRefetched.get(origin);
|
||||
const hit = refetch ? undefined : keysCache.get(origin);
|
||||
if (hit) return hit;
|
||||
if (refetch) keysRefetched.set(origin, true);
|
||||
const { jwks_uri } = await fetchMetadata(origin);
|
||||
const body = await readJson(await request(jwks_uri), `${origin}'s keys`);
|
||||
if (
|
||||
!body || typeof body !== "object" ||
|
||||
!Array.isArray((body as { keys?: unknown }).keys)
|
||||
) {
|
||||
throw new Error(`${origin}'s keys are not a key set`);
|
||||
}
|
||||
return keysCache.set(origin, body as JSONWebKeySet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades `code` for an identity token at the token endpoint of the instance
|
||||
* at `origin`, and returns the token, unverified.
|
||||
*
|
||||
* @throws {Error} When the instance refuses or answers no token.
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
origin: string,
|
||||
params: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const { token_endpoint } = await fetchMetadata(origin);
|
||||
const body = await readJson(
|
||||
await request(token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: params.code,
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
code_verifier: params.codeVerifier,
|
||||
}),
|
||||
}),
|
||||
`${origin}'s token endpoint`,
|
||||
);
|
||||
const idToken = (body as { id_token?: unknown } | null)?.id_token;
|
||||
if (typeof idToken !== "string") {
|
||||
throw new Error(`${origin} answered no identity token`);
|
||||
}
|
||||
return idToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the signed event `event` to the events endpoint of the instance at
|
||||
* `origin`, and returns whether it took it. A 4xx other than 429 means it never
|
||||
* will, which the caller treats as delivered.
|
||||
*
|
||||
* @throws {Error} When it can't be reached, or answers 429 or a 5xx.
|
||||
*/
|
||||
export async function postEvent(
|
||||
origin: string,
|
||||
event: string,
|
||||
): Promise<boolean> {
|
||||
const { gerbeur_events_endpoint } = await fetchMetadata(origin);
|
||||
const res = await request(gerbeur_events_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event }),
|
||||
});
|
||||
await res.body?.cancel();
|
||||
if (res.ok) return true;
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
throw new Error(`${origin}'s events endpoint answered ${res.status}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image at `url`, a user's avatar on their home instance, up to
|
||||
* the size an avatar may be.
|
||||
*
|
||||
* @throws {Error} When it can't be fetched.
|
||||
*/
|
||||
export async function fetchAvatar(url: string): Promise<Uint8Array> {
|
||||
const res = await request(url, { headers: { "Accept": "image/*" } });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new Error(`The avatar at ${url} answered ${res.status}`);
|
||||
}
|
||||
return await readBytesCapped(res, MAX_IMAGE_SIZE_BYTES + 1);
|
||||
}
|
||||
|
||||
/** Empties the caches, for the tests' reset between cases. */
|
||||
export function clearInstanceCaches(): void {
|
||||
metadataCache.clear();
|
||||
issuerCache.clear();
|
||||
keysCache.clear();
|
||||
keysRefetched.clear();
|
||||
}
|
||||
@@ -1,78 +1,230 @@
|
||||
/**
|
||||
* 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.
|
||||
* Reads a link preview's metadata out of a page's HTML. {@link parsePage}
|
||||
* scans the page once for the tags that carry it (`<meta>`, `<link>`, `<img>`,
|
||||
* `<title>` and JSON-LD scripts) without building a document, and returns a
|
||||
* {@link Page} that the providers' `genericPreview` picks from. Every field is
|
||||
* undefined when the page doesn't say.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
||||
.replace(
|
||||
/&#x([0-9a-f]+);/gi,
|
||||
(_, hex) => String.fromCodePoint(parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
import { hexOf, parseCssColor } from "../lib/css-color.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The named entities decoded besides numeric ones: the XML five, and those
|
||||
* page titles and descriptions actually use, Latin-1 letters and typography.
|
||||
*/
|
||||
function extractMetaContent(
|
||||
html: string,
|
||||
attr: string,
|
||||
value: string,
|
||||
): string | undefined {
|
||||
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i");
|
||||
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
|
||||
const tagRe = /<meta\b[^>]*>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tagRe.exec(html)) !== null) {
|
||||
const tag = m[0];
|
||||
if (!keyRe.test(tag)) continue;
|
||||
const content = contentRe.exec(tag);
|
||||
if (content) return decodeHtmlEntities(content[2]);
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
aelig: "æ",
|
||||
AElig: "Æ",
|
||||
oelig: "œ",
|
||||
OElig: "Œ",
|
||||
szlig: "ß",
|
||||
oslash: "ø",
|
||||
Oslash: "Ø",
|
||||
eth: "ð",
|
||||
ETH: "Ð",
|
||||
thorn: "þ",
|
||||
THORN: "Þ",
|
||||
hellip: "…",
|
||||
lsquo: "‘",
|
||||
rsquo: "’",
|
||||
sbquo: "‚",
|
||||
ldquo: "“",
|
||||
rdquo: "”",
|
||||
bdquo: "„",
|
||||
laquo: "«",
|
||||
raquo: "»",
|
||||
lsaquo: "‹",
|
||||
rsaquo: "›",
|
||||
ndash: "–",
|
||||
mdash: "—",
|
||||
bull: "•",
|
||||
middot: "·",
|
||||
dagger: "†",
|
||||
Dagger: "‡",
|
||||
prime: "′",
|
||||
Prime: "″",
|
||||
euro: "€",
|
||||
pound: "£",
|
||||
yen: "¥",
|
||||
cent: "¢",
|
||||
copy: "©",
|
||||
reg: "®",
|
||||
trade: "™",
|
||||
deg: "°",
|
||||
plusmn: "±",
|
||||
times: "×",
|
||||
divide: "÷",
|
||||
micro: "µ",
|
||||
para: "¶",
|
||||
sect: "§",
|
||||
iexcl: "¡",
|
||||
iquest: "¿",
|
||||
ordf: "ª",
|
||||
ordm: "º",
|
||||
sup2: "²",
|
||||
sup3: "³",
|
||||
frac14: "¼",
|
||||
frac12: "½",
|
||||
frac34: "¾",
|
||||
shy: "",
|
||||
ensp: " ",
|
||||
emsp: " ",
|
||||
thinsp: " ",
|
||||
zwnj: "",
|
||||
zwj: "",
|
||||
};
|
||||
|
||||
// The accented letters, `é` and the like, composed from their letter
|
||||
// and accent rather than listed one by one.
|
||||
for (
|
||||
const [accent, mark, letters] of [
|
||||
["grave", "̀", "aeiouAEIOU"],
|
||||
["acute", "́", "aeiouyAEIOUY"],
|
||||
["circ", "̂", "aeiouAEIOU"],
|
||||
["tilde", "̃", "anoANO"],
|
||||
["uml", "̈", "aeiouyAEIOUY"],
|
||||
["ring", "̊", "aA"],
|
||||
["cedil", "̧", "cC"],
|
||||
]
|
||||
) {
|
||||
for (const letter of letters) {
|
||||
NAMED_ENTITIES[letter + accent] = (letter + mark).normalize("NFC");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns the content of `<meta property="og:<tag>">`, entities decoded. */
|
||||
export function extractOgTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
): string | undefined {
|
||||
return extractMetaContent(html, "property", `og:${tag}`);
|
||||
/** Returns the character of code point `n`, or undefined when there is none. */
|
||||
function fromCodePoint(n: number): string | undefined {
|
||||
if (n <= 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) {
|
||||
return undefined;
|
||||
}
|
||||
return String.fromCodePoint(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of `<meta name="<name>">`, entities decoded. Twitter
|
||||
* card tags use `name`, not `property`.
|
||||
* Decodes the entities in `str` in a single pass, so `&quot;` reads
|
||||
* `"`. An entity it doesn't know, or a code point that doesn't exist, is
|
||||
* left as written. Also used by `bandcamp-tralbum.ts` on the JSON a page
|
||||
* carries in an attribute.
|
||||
*/
|
||||
export function extractMetaName(
|
||||
html: string,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
return extractMetaContent(html, "name", name);
|
||||
export function decodeHtmlEntities(str: string): string {
|
||||
return str.replace(
|
||||
/&(?:#(\d{1,8})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
|
||||
(match, dec?: string, hex?: string, name?: string) => {
|
||||
if (dec) return fromCodePoint(Number(dec)) ?? match;
|
||||
if (hex) return fromCodePoint(parseInt(hex, 16)) ?? match;
|
||||
return NAMED_ENTITIES[name!] ?? NAMED_ENTITIES[name!.toLowerCase()] ??
|
||||
match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* Returns `raw` resolved against `base` as an absolute http(s) URL; undefined
|
||||
* for a `data:` URI, another scheme or a URL that doesn't parse. Every URL a
|
||||
* page names goes through it, so `/og.png` and `//cdn/og.png` work.
|
||||
*/
|
||||
export function absoluteHttpUrl(
|
||||
raw: string | undefined,
|
||||
base: string,
|
||||
): string | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
try {
|
||||
const url = new URL(raw.trim(), base);
|
||||
return url.protocol === "http:" || url.protocol === "https:"
|
||||
? url.href
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A tag's attributes by lowercase name, values decoded; the first one wins. */
|
||||
type Attrs = Map<string, string>;
|
||||
|
||||
/**
|
||||
* Matches the start of a tag the scan reads, or of a comment. Quoted values
|
||||
* may hold `>`, but no tag spans a `<`, so each attempt stops at the next one
|
||||
* and the scan stays linear in the page's size.
|
||||
*/
|
||||
const TAG_RE =
|
||||
/<(meta|link|img|title|script)\b((?:[^<>"']|"[^"<]*"|'[^'<]*')*)>|<!--/gi;
|
||||
|
||||
/** Matches one attribute: unquoted, single- or double-quoted, or bare. */
|
||||
const ATTR_RE =
|
||||
/([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
|
||||
function parseAttrs(source: string): Attrs {
|
||||
const attrs: Attrs = new Map();
|
||||
for (const m of source.matchAll(ATTR_RE)) {
|
||||
const name = m[1].toLowerCase();
|
||||
if (attrs.has(name)) continue;
|
||||
attrs.set(name, decodeHtmlEntities(m[2] ?? m[3] ?? m[4] ?? ""));
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/** What {@link scanTags} found, in page order. */
|
||||
interface Scan {
|
||||
metas: Attrs[];
|
||||
links: Attrs[];
|
||||
imgs: Attrs[];
|
||||
/** The raw text of the first `<title>`. */
|
||||
title?: string;
|
||||
/** The bodies of the `application/ld+json` scripts. */
|
||||
jsonLd: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the tags {@link parsePage} reads in one pass. Comments and the
|
||||
* bodies of scripts and the title are skipped over, so a tag written inside
|
||||
* them isn't read; one left open runs to the end of the page, as in a browser.
|
||||
*/
|
||||
function scanTags(html: string): Scan {
|
||||
const scan: Scan = { metas: [], links: [], imgs: [], jsonLd: [] };
|
||||
const re = new RegExp(TAG_RE);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const tag = m[1]?.toLowerCase();
|
||||
if (!tag) {
|
||||
const end = html.indexOf("-->", re.lastIndex);
|
||||
if (end < 0) break;
|
||||
re.lastIndex = end + 3;
|
||||
continue;
|
||||
}
|
||||
const attrs = parseAttrs(m[2]);
|
||||
if (tag === "meta") scan.metas.push(attrs);
|
||||
else if (tag === "link") scan.links.push(attrs);
|
||||
else if (tag === "img") scan.imgs.push(attrs);
|
||||
else {
|
||||
const close = new RegExp(`</${tag}\\s*>`, "gi");
|
||||
close.lastIndex = re.lastIndex;
|
||||
const end = close.exec(html);
|
||||
if (!end) break;
|
||||
const body = html.slice(re.lastIndex, end.index);
|
||||
if (tag === "title") scan.title ??= body;
|
||||
else if (
|
||||
attrs.get("type")?.trim().toLowerCase() === "application/ld+json"
|
||||
) {
|
||||
scan.jsonLd.push(body);
|
||||
}
|
||||
re.lastIndex = close.lastIndex;
|
||||
}
|
||||
}
|
||||
return scan;
|
||||
}
|
||||
|
||||
/** Returns `value` trimmed, or undefined when nothing is left. */
|
||||
function nonBlank(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,21 +232,15 @@ export function extractPageTitle(html: string): string | undefined {
|
||||
* {@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;
|
||||
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
|
||||
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
|
||||
function themeColorOf(metas: Attrs[]): string | undefined {
|
||||
let unscoped: string | undefined;
|
||||
let light: string | undefined;
|
||||
let any: string | undefined;
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tagRe.exec(html)) !== null) {
|
||||
const tag = m[0];
|
||||
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
|
||||
const content = contentRe.exec(tag)?.[2];
|
||||
for (const meta of metas) {
|
||||
if (meta.get("name")?.toLowerCase() !== "theme-color") continue;
|
||||
const content = nonBlank(meta.get("content"));
|
||||
if (!content) continue;
|
||||
const media = mediaRe.exec(tag)?.[2];
|
||||
const media = meta.get("media");
|
||||
if (!media) unscoped ??= content;
|
||||
else if (/light/i.test(media)) light ??= content;
|
||||
any ??= content;
|
||||
@@ -102,239 +248,179 @@ export function extractThemeColor(html: string): string | undefined {
|
||||
return unscoped ?? light ?? any;
|
||||
}
|
||||
|
||||
/** Returns the lowercase tokens of a `<link>`'s `rel`. */
|
||||
function relTokens(link: Attrs): string[] {
|
||||
return (link.get("rel") ?? "").toLowerCase().split(/\s+/);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* as written. The generic preview's last resort for a brand color.
|
||||
*/
|
||||
export function extractMaskIconColor(html: string): string | undefined {
|
||||
const linkRe = /<link[^>]+>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRe.exec(html)) !== null) {
|
||||
const tag = m[0];
|
||||
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
|
||||
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
|
||||
function maskIconColorOf(links: Attrs[]): string | undefined {
|
||||
for (const link of links) {
|
||||
if (!relTokens(link).includes("mask-icon")) continue;
|
||||
const color = nonBlank(link.get("color"));
|
||||
if (color) return color;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
white: "#ffffff",
|
||||
red: "#ff0000",
|
||||
green: "#008000",
|
||||
blue: "#0000ff",
|
||||
yellow: "#ffff00",
|
||||
orange: "#ffa500",
|
||||
purple: "#800080",
|
||||
gray: "#808080",
|
||||
grey: "#808080",
|
||||
silver: "#c0c0c0",
|
||||
maroon: "#800000",
|
||||
navy: "#000080",
|
||||
teal: "#008080",
|
||||
olive: "#808000",
|
||||
lime: "#00ff00",
|
||||
aqua: "#00ffff",
|
||||
cyan: "#00ffff",
|
||||
fuchsia: "#ff00ff",
|
||||
magenta: "#ff00ff",
|
||||
};
|
||||
|
||||
function clampByte(n: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const value = raw.trim().toLowerCase();
|
||||
|
||||
const named = NAMED_COLORS[value];
|
||||
if (named) return named;
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
|
||||
if (hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const [r, g, b] = [...hex.slice(0, 3)];
|
||||
return `#${r}${r}${g}${g}${b}${b}`;
|
||||
}
|
||||
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
|
||||
if (rgb) {
|
||||
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
|
||||
if (parts.length !== 3) return undefined;
|
||||
const bytes = parts.map((p) => {
|
||||
const n = parseFloat(p);
|
||||
if (!Number.isFinite(n)) return NaN;
|
||||
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
|
||||
});
|
||||
if (bytes.some(Number.isNaN)) return undefined;
|
||||
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned to the generic provider by {@link extractJsonLd}, which ranks each
|
||||
* field below the page's OG and Twitter tags.
|
||||
*/
|
||||
export interface JsonLdResult {
|
||||
/** From `name`, or else `headline`. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
/**
|
||||
* From `image`, or else `thumbnailUrl` or `thumbnail`, and only when it is
|
||||
* an absolute URL.
|
||||
*/
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
function ldString(v: unknown): string | undefined {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) {
|
||||
return v[0].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ldImage(v: unknown): string | undefined {
|
||||
if (
|
||||
typeof v === "string" &&
|
||||
(v.startsWith("http://") || v.startsWith("https://"))
|
||||
) return v;
|
||||
if (Array.isArray(v)) return ldImage(v[0]);
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return ldImage(o.url ?? o.contentUrl);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ldExtractNode(data: unknown): JsonLdResult {
|
||||
if (Array.isArray(data)) {
|
||||
for (const item of data) {
|
||||
const r = ldExtractNode(item);
|
||||
if (r.title || r.thumbnailUrl) return r;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (!data || typeof data !== "object") return {};
|
||||
const o = data as Record<string, unknown>;
|
||||
if (o["@graph"]) return ldExtractNode(o["@graph"]);
|
||||
return {
|
||||
title: ldString(o.name ?? o.headline),
|
||||
description: ldString(o.description),
|
||||
thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
try {
|
||||
const result = ldExtractNode(JSON.parse(match[1]));
|
||||
if (result.title || result.thumbnailUrl) return result;
|
||||
} catch { /* invalid JSON: try the next block */ }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
baseUrl: string,
|
||||
minSize = 200,
|
||||
): string | undefined {
|
||||
const imgPattern = /<img[^>]+>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imgPattern.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
|
||||
if (!src || src.startsWith("data:")) continue;
|
||||
const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
|
||||
const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0");
|
||||
if (w >= minSize && h >= minSize) {
|
||||
try {
|
||||
return new URL(src, baseUrl).toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* declares a size. A `mask-icon` is no candidate: it is a one-color shape for
|
||||
* Safari to tint, black when drawn as is.
|
||||
*
|
||||
* An SVG icon wins over any size: it declares none, yet scales to any.
|
||||
*/
|
||||
export function extractBestIcon(
|
||||
html: string,
|
||||
baseUrl: string,
|
||||
): string | undefined {
|
||||
const linkRe = /<link[^>]+>/gi;
|
||||
const relRe = /\brel=["']([^"']+)["']/i;
|
||||
const hrefRe = /\bhref=["']([^"']+)["']/i;
|
||||
const sizesRe = /\bsizes=["']([^"']+)["']/i;
|
||||
|
||||
const candidates: { href: string; area: number }[] = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRe.exec(html)) !== null) {
|
||||
const tag = m[0];
|
||||
const rel = relRe.exec(tag)?.[1] ?? "";
|
||||
if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue;
|
||||
const href = hrefRe.exec(tag)?.[1];
|
||||
function bestIconOf(links: Attrs[], baseUrl: string): string | undefined {
|
||||
let best: { href: string; area: number } | undefined;
|
||||
for (const link of links) {
|
||||
const rel = relTokens(link);
|
||||
if (
|
||||
!rel.includes("icon") && !rel.includes("apple-touch-icon") &&
|
||||
!rel.includes("apple-touch-icon-precomposed")
|
||||
) continue;
|
||||
const href = absoluteHttpUrl(link.get("href"), baseUrl);
|
||||
if (!href) continue;
|
||||
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
||||
const sm = sizesStr.match(/(\d+)x(\d+)/i);
|
||||
const sizes = (link.get("sizes") ?? "").toLowerCase();
|
||||
const isSvg = /\.svg(\?|$)/i.test(href) ||
|
||||
/\btype=["']image\/svg\+xml["']/i.test(tag);
|
||||
const area = isSvg
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: sm
|
||||
? parseInt(sm[1]) * parseInt(sm[2])
|
||||
: 0;
|
||||
link.get("type")?.toLowerCase() === "image/svg+xml" ||
|
||||
sizes === "any";
|
||||
const area = isSvg ? Number.MAX_SAFE_INTEGER : Math.max(
|
||||
0,
|
||||
...[...sizes.matchAll(/(\d+)x(\d+)/g)].map(([, w, h]) => +w * +h),
|
||||
);
|
||||
if (!best || area > best.area) best = { href, area };
|
||||
}
|
||||
return best?.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by the JSON-LD reading of {@link parsePage}, which the generic
|
||||
* preview ranks below the page's OG and Twitter tags.
|
||||
*/
|
||||
export interface JsonLdResult {
|
||||
/** From `name`, or else `headline`. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** From `image`, or else `thumbnailUrl` or `thumbnail`. */
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON-LD types that describe the site around a page rather than the
|
||||
* page itself. Their name is the site's or the company's, so a node of one of
|
||||
* them is read only when no other node has a title or an image.
|
||||
*/
|
||||
const SITE_LD_TYPES = new Set([
|
||||
"WebSite",
|
||||
"Organization",
|
||||
"Corporation",
|
||||
"NewsMediaOrganization",
|
||||
"Person",
|
||||
"BreadcrumbList",
|
||||
"SearchAction",
|
||||
"ImageObject",
|
||||
"SiteNavigationElement",
|
||||
"WPHeader",
|
||||
"WPFooter",
|
||||
]);
|
||||
|
||||
function ldString(v: unknown): string | undefined {
|
||||
if (typeof v === "string") return nonBlank(v);
|
||||
if (Array.isArray(v) && typeof v[0] === "string") return nonBlank(v[0]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ldImage(v: unknown, baseUrl: string): string | undefined {
|
||||
if (typeof v === "string") return absoluteHttpUrl(v, baseUrl);
|
||||
if (Array.isArray(v)) return ldImage(v[0], baseUrl);
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return ldImage(o.url ?? o.contentUrl, baseUrl);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Lists every node of `data`, through arrays and `@graph`, in order. */
|
||||
function ldNodes(data: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(data)) return data.flatMap(ldNodes);
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const o = data as Record<string, unknown>;
|
||||
return o["@graph"] ? ldNodes(o["@graph"]) : [o];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first JSON-LD node about the page that has a title or an image,
|
||||
* else the first about its site ({@link SITE_LD_TYPES}); 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.
|
||||
*/
|
||||
function jsonLdOf(blocks: string[], baseUrl: string): JsonLdResult {
|
||||
let fallback: JsonLdResult | undefined;
|
||||
for (const block of blocks) {
|
||||
let data: unknown;
|
||||
try {
|
||||
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
||||
data = JSON.parse(block);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const node of ldNodes(data)) {
|
||||
const result: JsonLdResult = {
|
||||
title: ldString(node.name ?? node.headline),
|
||||
description: ldString(node.description),
|
||||
thumbnailUrl: ldImage(
|
||||
node.image ?? node.thumbnailUrl ?? node.thumbnail,
|
||||
baseUrl,
|
||||
),
|
||||
};
|
||||
if (!result.title && !result.thumbnailUrl) continue;
|
||||
const types = [node["@type"]].flat();
|
||||
if (!types.some((t) => typeof t === "string" && SITE_LD_TYPES.has(t))) {
|
||||
return result;
|
||||
}
|
||||
fallback ??= result;
|
||||
}
|
||||
}
|
||||
return fallback ?? {};
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return undefined;
|
||||
candidates.sort((a, b) => b.area - a.area);
|
||||
return candidates[0].href;
|
||||
/**
|
||||
* Returns the source of an `<img>`: its `src`, or for a lazy-loaded image
|
||||
* whose `src` is a `data:` placeholder, its `data-src` or `data-lazy-src`.
|
||||
*/
|
||||
function imageSource(img: Attrs, baseUrl: string): string | undefined {
|
||||
const src = img.get("src")?.trim();
|
||||
return src && !src.startsWith("data:")
|
||||
? absoluteHttpUrl(src, baseUrl)
|
||||
: absoluteHttpUrl(img.get("data-src") ?? img.get("data-lazy-src"), baseUrl);
|
||||
}
|
||||
|
||||
/** Returns an `<img>`'s declared `name` dimension in pixels; 0 when none. */
|
||||
function declaredSize(img: Attrs, name: "width" | "height"): number {
|
||||
return parseInt(img.get(name) ?? "") || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function largeImageOf(
|
||||
imgs: Attrs[],
|
||||
baseUrl: string,
|
||||
minSize = 200,
|
||||
): string | undefined {
|
||||
for (const img of imgs) {
|
||||
if (
|
||||
declaredSize(img, "width") < minSize ||
|
||||
declaredSize(img, "height") < minSize
|
||||
) continue;
|
||||
const src = imageSource(img, baseUrl);
|
||||
if (src) return src;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Filename words that mark an image as page furniture rather than content. */
|
||||
@@ -354,6 +440,11 @@ const UI_IMAGE_KEYWORDS = new Set([
|
||||
"pixel",
|
||||
"bg",
|
||||
"background",
|
||||
"placeholder",
|
||||
"spacer",
|
||||
"blank",
|
||||
"lazy",
|
||||
"loading",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -371,25 +462,86 @@ function isUiImage(src: string): boolean {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* than a logo, icon, tracking pixel or other page furniture: one declared
|
||||
* smaller than 50 pixels either way doesn't count. The generic preview's last
|
||||
* resort for a thumbnail.
|
||||
*/
|
||||
export function extractFirstContentImage(
|
||||
html: string,
|
||||
function firstContentImageOf(
|
||||
imgs: Attrs[],
|
||||
baseUrl: string,
|
||||
): string | undefined {
|
||||
const imgPattern = /<img[^>]+>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imgPattern.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1];
|
||||
if (!src || src.startsWith("data:")) continue;
|
||||
if (isUiImage(src)) continue;
|
||||
try {
|
||||
return new URL(src, baseUrl).toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const img of imgs) {
|
||||
const w = declaredSize(img, "width");
|
||||
const h = declaredSize(img, "height");
|
||||
if ((w && w < 50) || (h && h < 50)) continue;
|
||||
const src = imageSource(img, baseUrl);
|
||||
if (src && !isUiImage(src)) return src;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by {@link parsePage}: what a page says about itself, entities
|
||||
* decoded, values trimmed and URLs absolute.
|
||||
*/
|
||||
export interface Page {
|
||||
/**
|
||||
* Returns the `content` of the first `<meta>` whose `property` or `name` is
|
||||
* `key`, in any case: sites write `og:` tags under either attribute, and
|
||||
* Twitter's too.
|
||||
*/
|
||||
meta(key: string): string | undefined;
|
||||
/** The text of the `<title>` element. */
|
||||
title?: string;
|
||||
jsonLd: JsonLdResult;
|
||||
/** The `theme-color`, as written: pass it to {@link normalizeCssColor}. */
|
||||
themeColor?: string;
|
||||
/** The `color` of the Safari pinned-tab icon, as written. */
|
||||
maskIconColor?: string;
|
||||
/** The largest icon the page links, which the thumbnail placeholder draws. */
|
||||
bestIcon?: string;
|
||||
/** The first image declared at least 200 pixels both ways. */
|
||||
largeImage?: string;
|
||||
/** The first image that doesn't look like page furniture. */
|
||||
firstContentImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `html`, served from `pageUrl`, in one pass. The generic preview and
|
||||
* the providers built on it read a page through it.
|
||||
*/
|
||||
export function parsePage(html: string, pageUrl: string): Page {
|
||||
const scan = scanTags(html);
|
||||
|
||||
const metas = new Map<string, string>();
|
||||
for (const meta of scan.metas) {
|
||||
const content = nonBlank(meta.get("content"));
|
||||
if (!content) continue;
|
||||
for (const attr of ["property", "name"]) {
|
||||
const key = meta.get(attr)?.trim().toLowerCase();
|
||||
if (key && !metas.has(key)) metas.set(key, content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
meta: (key) => metas.get(key.toLowerCase()),
|
||||
title: nonBlank(scan.title && decodeHtmlEntities(scan.title)),
|
||||
jsonLd: jsonLdOf(scan.jsonLd, pageUrl),
|
||||
themeColor: themeColorOf(scan.metas),
|
||||
maskIconColor: maskIconColorOf(scan.links),
|
||||
bestIcon: bestIconOf(scan.links, pageUrl),
|
||||
largeImage: largeImageOf(scan.imgs, pageUrl),
|
||||
firstContentImage: firstContentImageOf(scan.imgs, pageUrl),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `raw` as a lowercase `#rrggbb`, from a hex, `rgb()`, `rgba()` or
|
||||
* common named color; undefined for any other form. The generic preview
|
||||
* stores it as the preview's `accentColor`, a tint over the app's own surface,
|
||||
* so alpha is dropped, and a fully transparent one is no color at all.
|
||||
*/
|
||||
export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||
const color = raw ? parseCssColor(raw) : undefined;
|
||||
return color && color.a > 0 ? hexOf(color) : undefined;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
* Shows the thumbnails a browser can't load from their own site: those on plain
|
||||
* http, which an https page would block, and those behind hotlink protection.
|
||||
* It fetches and caches the images `GET /api/remote/image` serves, which the
|
||||
* frontend routes a thumbnail through when it is on http or when loading it
|
||||
* directly failed, and scales them down as the site's own are.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
|
||||
import type { ImageWidth } from "../../shared/image.ts";
|
||||
import { scaleImageBytes } from "../lib/ffmpeg.ts";
|
||||
import { scalableImage } from "../lib/upload.ts";
|
||||
|
||||
/**
|
||||
* Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends
|
||||
* Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends
|
||||
* it back under `contentType`.
|
||||
*/
|
||||
export interface ProxiedImage {
|
||||
@@ -33,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
/**
|
||||
* 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.
|
||||
* Proxied images, by URL and by the width they were scaled to, 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,
|
||||
@@ -88,15 +92,33 @@ export function clearImageCache(): void {
|
||||
/**
|
||||
* Returns the image at `url`, from the cache or else from its host; null when
|
||||
* 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.
|
||||
* at most. With `width`, it comes as a WebP copy scaled down to it, or as it
|
||||
* is when no copy can be made (`scalableImage`, and ffmpeg). The route checks
|
||||
* `url` with `isValidHttpUrl` first.
|
||||
*
|
||||
* @throws {Error} When the host can't be reached, as
|
||||
* {@link fetchWithTimeout} reports it.
|
||||
*/
|
||||
export async function fetchProxiedImage(
|
||||
url: string,
|
||||
width?: ImageWidth | null,
|
||||
): Promise<ProxiedImage | null> {
|
||||
if (!width) return await fetchOriginal(url);
|
||||
|
||||
const key = `${url}#w${width}`;
|
||||
const cached = readImageCache(key);
|
||||
if (cached) return cached;
|
||||
const original = await fetchOriginal(url);
|
||||
if (!original) return null;
|
||||
const how = scalableImage(original.bytes);
|
||||
const bytes = how && await scaleImageBytes(original.bytes, width, how);
|
||||
if (!bytes) return original;
|
||||
writeImageCache(key, bytes, "image/webp");
|
||||
return { bytes, contentType: "image/webp" };
|
||||
}
|
||||
|
||||
/** Returns the image at `url` as its host sends it: see above. */
|
||||
async function fetchOriginal(url: string): Promise<ProxiedImage | null> {
|
||||
const cached = readImageCache(url);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
/**
|
||||
* Previews Bandcamp links from their page's OG tags, for
|
||||
* `rich-content-service.ts`.
|
||||
* Previews Bandcamp links from their page, for `rich-content-service.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { parsePage } from "../html.ts";
|
||||
import { fetchPage } from "../fetch.ts";
|
||||
import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts";
|
||||
import { genericPreview } from "./generic.ts";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Returns the embed player's URL a page's `og:video` names, over https; only
|
||||
* a player on bandcamp.com counts, since the frontend frames it.
|
||||
*/
|
||||
function embeddedPlayerUrl(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (
|
||||
(url.protocol !== "https:" && url.protocol !== "http:") ||
|
||||
url.hostname !== "bandcamp.com" ||
|
||||
!url.pathname.startsWith("/EmbeddedPlayer/")
|
||||
) return undefined;
|
||||
url.protocol = "https:";
|
||||
return url.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims bandcamp.com and its subdomains, where artists have their pages, and
|
||||
* 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/remote/bandcamp`.
|
||||
*/
|
||||
export const bandcampProvider: RichContentProvider = {
|
||||
name: "bandcamp",
|
||||
@@ -28,24 +49,16 @@ export const bandcampProvider: RichContentProvider = {
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
await res.body?.cancel();
|
||||
const fetched = await fetchPage(url);
|
||||
if (fetched.html === undefined) {
|
||||
return { type: "bandcamp", siteName: "Bandcamp", url };
|
||||
}
|
||||
|
||||
const html = await readTextCapped(res);
|
||||
|
||||
const page = parsePage(fetched.html, fetched.url);
|
||||
return {
|
||||
...genericPreview(page, url, fetched.url),
|
||||
type: "bandcamp",
|
||||
siteName: "Bandcamp",
|
||||
url,
|
||||
title: extractOgTag(html, "title"),
|
||||
description: extractOgTag(html, "description"),
|
||||
thumbnailUrl: extractOgTag(html, "image"),
|
||||
embedUrl: extractOgTag(html, "video") ?? undefined,
|
||||
embedUrl: embeddedPlayerUrl(page.meta("og:video")),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,32 +1,107 @@
|
||||
/**
|
||||
* Previews any page no other provider claims, for `rich-content-service.ts`,
|
||||
* from whatever metadata its HTML carries.
|
||||
* from whatever metadata its HTML carries. Its reading of a page,
|
||||
* {@link genericPreview}, is also the base of the Bandcamp, SoundCloud and
|
||||
* YouTube channel previews.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { siteHost } from "../../../shared/url.ts";
|
||||
import {
|
||||
extractBestIcon,
|
||||
extractFirstContentImage,
|
||||
extractJsonLd,
|
||||
extractLargeImage,
|
||||
extractMaskIconColor,
|
||||
extractMetaName,
|
||||
extractOgTag,
|
||||
extractPageTitle,
|
||||
extractThemeColor,
|
||||
absoluteHttpUrl,
|
||||
normalizeCssColor,
|
||||
type Page,
|
||||
parsePage,
|
||||
} from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { fetchPage } from "../fetch.ts";
|
||||
|
||||
/** Returns the path of `url` without its trailing slashes, `/` for the root. */
|
||||
function pathOf(url: string): string {
|
||||
return new URL(url).pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Strips a trailing ` | Site`, ` – Site` or the like from a `<title>`, which
|
||||
* names the page and then its site; the site is in the preview's badge.
|
||||
*/
|
||||
function withoutSiteSuffix(title: string, siteName: string): string {
|
||||
const escaped = siteName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const suffix = new RegExp(`\\s+[|–—\\-·•]\\s+${escaped}\\s*$`, "i");
|
||||
return title.replace(suffix, "") || title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview of the page `url` that `page` read, served from
|
||||
* `finalUrl` after redirects. 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 function genericPreview(
|
||||
page: Page,
|
||||
url: string,
|
||||
finalUrl: string,
|
||||
): RichContent {
|
||||
// An og:url naming the home page from another page means the OG tags are
|
||||
// the site's defaults, not this page's: their title and image are skipped.
|
||||
// Any other og:url is the page's canonical address, often with a slug the
|
||||
// link left out, and its tags are the page's.
|
||||
const ogUrl = page.meta("og:url");
|
||||
let useOg = true;
|
||||
try {
|
||||
useOg = !ogUrl || pathOf(new URL(ogUrl, finalUrl).href) !== "/" ||
|
||||
pathOf(finalUrl) === "/";
|
||||
} catch { /* an og:url that doesn't parse names no other page */ }
|
||||
|
||||
const ogSiteName = page.meta("og:site_name");
|
||||
const pageTitle = page.title && ogSiteName
|
||||
? withoutSiteSuffix(page.title, ogSiteName)
|
||||
: page.title;
|
||||
const title = (useOg ? page.meta("og:title") : undefined) ??
|
||||
page.meta("twitter:title") ??
|
||||
page.jsonLd.title ??
|
||||
pageTitle;
|
||||
|
||||
const description = page.meta("og:description") ??
|
||||
page.meta("twitter:description") ??
|
||||
page.jsonLd.description ??
|
||||
page.meta("description");
|
||||
|
||||
// Never the favicon: a 16×16 icon cropped into a thumbnail looks broken,
|
||||
// and the placeholder drawn instead shows the icon properly. A tag naming
|
||||
// no usable URL falls through to the next.
|
||||
const thumbnailUrl =
|
||||
(useOg ? absoluteHttpUrl(page.meta("og:image"), finalUrl) : undefined) ??
|
||||
absoluteHttpUrl(page.meta("twitter:image"), finalUrl) ??
|
||||
page.jsonLd.thumbnailUrl ??
|
||||
page.largeImage ??
|
||||
page.firstContentImage;
|
||||
|
||||
// `/favicon.ico` is a guess. When it doesn't load, the placeholder shows
|
||||
// the site's initial.
|
||||
const faviconUrl = page.bestIcon ?? `${new URL(finalUrl).origin}/favicon.ico`;
|
||||
|
||||
const accentColor = normalizeCssColor(
|
||||
page.themeColor ??
|
||||
page.meta("msapplication-TileColor") ??
|
||||
page.maskIconColor,
|
||||
);
|
||||
|
||||
return {
|
||||
type: "generic",
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
faviconUrl,
|
||||
accentColor,
|
||||
siteName: ogSiteName ?? siteHost(url),
|
||||
};
|
||||
}
|
||||
|
||||
/** Matches every URL, so it comes last in the service's list. */
|
||||
export const genericProvider: RichContentProvider = {
|
||||
name: "generic",
|
||||
|
||||
@@ -35,72 +110,12 @@ export const genericProvider: RichContentProvider = {
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
await res.body?.cancel();
|
||||
return { type: "generic", url };
|
||||
}
|
||||
|
||||
const html = await readTextCapped(res);
|
||||
const ld = extractJsonLd(html);
|
||||
|
||||
// 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 {
|
||||
const ogPath = new URL(ogUrl).pathname.replace(/\/+$/, "") || "/";
|
||||
const pagePath = new URL(url).pathname.replace(/\/+$/, "") || "/";
|
||||
return ogPath === pagePath;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
const title = (useOg ? extractOgTag(html, "title") : undefined) ??
|
||||
extractMetaName(html, "twitter:title") ??
|
||||
ld.title ??
|
||||
extractPageTitle(html);
|
||||
|
||||
const siteName = extractOgTag(html, "site_name") ??
|
||||
new URL(url).hostname.replace(/^www\./, "");
|
||||
|
||||
const description = extractOgTag(html, "description") ??
|
||||
extractMetaName(html, "twitter:description") ??
|
||||
ld.description ??
|
||||
extractMetaName(html, "description");
|
||||
|
||||
// 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);
|
||||
|
||||
// `/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`;
|
||||
|
||||
const accentColor = normalizeCssColor(
|
||||
extractThemeColor(html) ??
|
||||
extractMetaName(html, "msapplication-TileColor") ??
|
||||
extractMaskIconColor(html),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "generic",
|
||||
const fetched = await fetchPage(url);
|
||||
if (fetched.html === undefined) return { type: "generic", url };
|
||||
return genericPreview(
|
||||
parsePage(fetched.html, fetched.url),
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
faviconUrl,
|
||||
accentColor,
|
||||
siteName,
|
||||
};
|
||||
fetched.url,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Lets each kind of site have its own link previews. A provider implements this
|
||||
* interface, and `rich-content-service.ts` asks its providers in turn and
|
||||
* fetches a link's preview from the first that matches.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,9 +18,10 @@ export interface RichContentProvider {
|
||||
matches(url: string): boolean;
|
||||
/**
|
||||
* 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.
|
||||
* the create form's preview. It rejects when the page can't be fetched or
|
||||
* answers with an error status, and the service then falls back to a stub.
|
||||
* A page that isn't HTML yields a preview without metadata. The service
|
||||
* tidies what it returns, so a provider leaves text as the page wrote it.
|
||||
*/
|
||||
fetch(url: string): Promise<RichContent>;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
* Previews links to this site's own dump, user and playlist pages, as
|
||||
* `parsePagePath` reads them, 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
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { getDump } from "../../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../../services/user-service.ts";
|
||||
import { getPlaylistById } from "../../services/playlist-service.ts";
|
||||
import { getDumpByPath } from "../../services/dump-service.ts";
|
||||
import { resolveUsername } from "../../services/user-service.ts";
|
||||
import { getPlaylistByPath } 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";
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
const USER_RE = /^\/users\/([^/]+)$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
import { parsePagePath } from "../../../shared/paths.ts";
|
||||
|
||||
/**
|
||||
* Gives a link to this site without a thumbnail a placeholder in the site's
|
||||
@@ -30,10 +28,10 @@ const SELF_BRANDING = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Comes before the generic provider, and claims what `isSelfUrl` accepts. A
|
||||
* profile's tab previews as the profile. 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",
|
||||
@@ -47,14 +45,15 @@ export const selfProvider: RichContentProvider = {
|
||||
},
|
||||
|
||||
fetch(url: string): Promise<RichContent> {
|
||||
const { pathname } = new URL(url);
|
||||
// matches() let only these paths through.
|
||||
const page = parsePagePath(new URL(url).pathname)!;
|
||||
const user = resolveUsername(page.username);
|
||||
|
||||
const dumpMatch = pathname.match(DUMP_RE);
|
||||
if (dumpMatch) {
|
||||
const dump = getDump(dumpMatch[1]);
|
||||
if (page.kind === "dump") {
|
||||
const dump = getDumpByPath(user.id, page.slug);
|
||||
let thumbnailUrl: string | undefined;
|
||||
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) {
|
||||
thumbnailUrl = `${PUBLIC_URL}/api/files/${dump.id}`;
|
||||
thumbnailUrl = `${PUBLIC_URL}/api/dumps/${dump.id}/file`;
|
||||
} else if (dump.richContent?.thumbnailUrl) {
|
||||
thumbnailUrl = dump.richContent.thumbnailUrl;
|
||||
}
|
||||
@@ -68,30 +67,13 @@ export const selfProvider: RichContentProvider = {
|
||||
});
|
||||
}
|
||||
|
||||
const userMatch = pathname.match(USER_RE);
|
||||
if (userMatch) {
|
||||
const user = getUserByUsername(userMatch[1]);
|
||||
const thumbnailUrl = user.avatarMime
|
||||
? `${PUBLIC_URL}/api/avatars/${user.id}`
|
||||
: undefined;
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
url,
|
||||
...SELF_BRANDING,
|
||||
title: user.username,
|
||||
description: user.description,
|
||||
thumbnailUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const playlistMatch = pathname.match(PLAYLIST_RE);
|
||||
if (playlistMatch) {
|
||||
const playlist = getPlaylistById(playlistMatch[1]);
|
||||
if (page.kind === "playlist") {
|
||||
const playlist = getPlaylistByPath(user.id, page.slug);
|
||||
if (!playlist.isPublic) {
|
||||
throw new APIException(404, "Playlist not found");
|
||||
}
|
||||
const thumbnailUrl = playlist.imageMime
|
||||
? `${PUBLIC_URL}/api/playlists/${playlist.id}/image`
|
||||
? `${PUBLIC_URL}/api/playlists/${playlist.id}/cover`
|
||||
: undefined;
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
@@ -103,7 +85,16 @@ export const selfProvider: RichContentProvider = {
|
||||
});
|
||||
}
|
||||
|
||||
// Unreachable: isSelfUrl accepts only the three paths above.
|
||||
return Promise.resolve({ type: "generic", url });
|
||||
const thumbnailUrl = user.avatarMime
|
||||
? `${PUBLIC_URL}/api/users/${user.id}/avatar`
|
||||
: undefined;
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
url,
|
||||
...SELF_BRANDING,
|
||||
title: user.username,
|
||||
description: user.description,
|
||||
thumbnailUrl,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,50 +1,72 @@
|
||||
/**
|
||||
* Previews SoundCloud links from their page's OG tags, for
|
||||
* `rich-content-service.ts`.
|
||||
* Previews SoundCloud links from their page, for `rich-content-service.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { parsePage } from "../html.ts";
|
||||
import { fetchPage } from "../fetch.ts";
|
||||
import { genericPreview } from "./generic.ts";
|
||||
|
||||
/** SoundCloud's own hosts, the short links' `on.` included. */
|
||||
const SOUNDCLOUD_HOSTS = new Set([
|
||||
"soundcloud.com",
|
||||
"www.soundcloud.com",
|
||||
"m.soundcloud.com",
|
||||
"on.soundcloud.com",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Claims soundcloud.com only, not www. or m.soundcloud.com, and builds the
|
||||
* embed player's URL from the link itself.
|
||||
* Returns the soundcloud.com address of the page, which the embed player
|
||||
* takes: its `og:url`, or else the URL it was served from. A short link only
|
||||
* redirects, so it is never the answer; tracking parameters are dropped.
|
||||
*/
|
||||
function pageAddress(ogUrl: string | undefined, finalUrl: string): string {
|
||||
for (const raw of [ogUrl, finalUrl]) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (
|
||||
!SOUNDCLOUD_HOSTS.has(url.hostname) ||
|
||||
url.hostname === "on.soundcloud.com"
|
||||
) continue;
|
||||
return `https://soundcloud.com${url.pathname}`;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims SoundCloud's hosts, short links included, and builds the embed
|
||||
* player's URL from the page's address.
|
||||
*/
|
||||
export const soundcloudProvider: RichContentProvider = {
|
||||
name: "soundcloud",
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).hostname === "soundcloud.com";
|
||||
return SOUNDCLOUD_HOSTS.has(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
await res.body?.cancel();
|
||||
return { type: "soundcloud", siteName: "SoundCloud", url };
|
||||
}
|
||||
|
||||
const html = await readTextCapped(res);
|
||||
|
||||
const fetched = await fetchPage(url);
|
||||
const page = fetched.html === undefined
|
||||
? undefined
|
||||
: parsePage(fetched.html, fetched.url);
|
||||
const address = pageAddress(page?.meta("og:url"), fetched.url);
|
||||
return {
|
||||
...(page ? genericPreview(page, url, fetched.url) : { url }),
|
||||
type: "soundcloud",
|
||||
siteName: "SoundCloud",
|
||||
url,
|
||||
title: extractOgTag(html, "title"),
|
||||
description: extractOgTag(html, "description"),
|
||||
thumbnailUrl: extractOgTag(html, "image"),
|
||||
embedUrl: `https://w.soundcloud.com/player/?url=${
|
||||
encodeURIComponent(url)
|
||||
encodeURIComponent(address)
|
||||
}&visual=true&auto_play=false`,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
/**
|
||||
* 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.
|
||||
* oEmbed endpoint, channels from their page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
import type { RichContentProvider } from "./provider.ts";
|
||||
import { extractOgTag } from "../html.ts";
|
||||
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
|
||||
import { parsePage } from "../html.ts";
|
||||
import { fetchPage, fetchWithTimeout } from "../fetch.ts";
|
||||
import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts";
|
||||
import { siteHostOf } from "../../../shared/url.ts";
|
||||
import { genericPreview } from "./generic.ts";
|
||||
|
||||
/** 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);
|
||||
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||
const host = siteHostOf(u);
|
||||
return youtubeVideoId(host, u.pathname, u.searchParams);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -122,27 +124,17 @@ export const youtubeProvider: RichContentProvider = {
|
||||
|
||||
if (channelPath && !videoId) {
|
||||
// oEmbed doesn't take channel URLs.
|
||||
let title: string | undefined;
|
||||
let thumbnailUrl: string | undefined;
|
||||
let channel: RichContent | undefined;
|
||||
try {
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (res.ok) {
|
||||
const html = await readTextCapped(res);
|
||||
title = extractOgTag(html, "title");
|
||||
thumbnailUrl = extractOgTag(html, "image");
|
||||
} else {
|
||||
await res.body?.cancel();
|
||||
const fetched = await fetchPage(url);
|
||||
if (fetched.html !== undefined) {
|
||||
const page = parsePage(fetched.html, fetched.url);
|
||||
channel = genericPreview(page, url, fetched.url);
|
||||
}
|
||||
} catch {
|
||||
// YouTube unreachable: the preview goes without.
|
||||
}
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
};
|
||||
return { ...channel, type: "youtube", siteName: "YouTube", url };
|
||||
}
|
||||
|
||||
if (listId && !videoId) {
|
||||
@@ -171,7 +163,6 @@ export const youtubeProvider: RichContentProvider = {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
videoId: videoId!,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Keeps a user-supplied URL from making the server fetch what only it can
|
||||
* reach: loopback, the LAN, container networks or the cloud metadata endpoint
|
||||
* at 169.254.169.254. It tells private addresses from public ones, and
|
||||
* `fetch.ts` checks every hop with it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -100,9 +100,10 @@ function isIpLiteral(host: string): boolean {
|
||||
|
||||
/**
|
||||
* Returns `url`'s hostname without the brackets `URL.hostname` keeps around
|
||||
* an IPv6 literal.
|
||||
* an IPv6 literal: the address checks here read it, and `fetch.ts` hands it
|
||||
* to curl's `--resolve`.
|
||||
*/
|
||||
function bareHostname(url: URL): string {
|
||||
export function bareHostname(url: URL): string {
|
||||
return url.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Gives the global player a tracklist for a YouTube playlist, which the embed
|
||||
* plays but doesn't list. It resolves the playlist to its entries' titles and
|
||||
* lengths, for `GET /api/remote/youtube`.
|
||||
*
|
||||
* They are read from the `ytInitialData` JSON of the public playlist page,
|
||||
* whose undocumented shape changes, so both the current `lockupViewModel` rows
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { TtlCache } from "../lib/ttl-cache.ts";
|
||||
import { readTextCapped } from "./fetch.ts";
|
||||
import type {
|
||||
YouTubePlaylist,
|
||||
@@ -20,11 +21,7 @@ import type {
|
||||
} from "../../shared/player.ts";
|
||||
|
||||
/** Titles and lengths rarely change, and a stale one only mislabels a row. */
|
||||
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** The oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
|
||||
const cache = new TtlCache<YouTubePlaylist>(6 * 3_600_000, 200);
|
||||
|
||||
/**
|
||||
* Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else.
|
||||
@@ -167,8 +164,7 @@ export async function resolveYouTubePlaylist(
|
||||
}
|
||||
|
||||
const hit = cache.get(listId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
|
||||
if (hit) cache.delete(listId);
|
||||
if (hit) return hit;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
@@ -198,10 +194,5 @@ export async function resolveYouTubePlaylist(
|
||||
throw new APIException(404, "No tracks on this playlist");
|
||||
}
|
||||
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(listId, { value: parsed, at: Date.now() });
|
||||
return parsed;
|
||||
return cache.set(listId, parsed);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*
|
||||
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
|
||||
* 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.
|
||||
* - `GET /api/attachments/:attachmentId?w=&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, scaled down to `w`
|
||||
* (`shared/image.ts`) when it names one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
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 type { AttachmentResponse } from "../../shared/attachment.ts";
|
||||
import { serveUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { ATTACHMENTS_DIR } from "../config.ts";
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
* Stores and serves the users' avatars. The profile page uploads the avatar,
|
||||
* and the client's `Avatar` component loads it at the size it shows, with the
|
||||
* user's `updatedAt` as a cache-busting version.
|
||||
*
|
||||
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
|
||||
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
|
||||
* `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.
|
||||
* caller's user. 400 for an account from another instance, whose avatar
|
||||
* comes from there. The instances the caller signed in to are told.
|
||||
* - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled
|
||||
* down to `w` (`shared/image.ts`) when it names one; 404 without one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
|
||||
import {
|
||||
broadcastUserUpdated,
|
||||
updateClientAvatar,
|
||||
} from "../services/ws-service.ts";
|
||||
assertLocalUser,
|
||||
getUserById,
|
||||
resolveUserKey,
|
||||
setUserAvatar,
|
||||
} from "../services/user-service.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 {
|
||||
replaceUploadedFile,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { toSelfUser } from "../model/user.ts";
|
||||
import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import { notifyClients } from "../services/identity-provider-service.ts";
|
||||
import { AVATARS_DIR } from "../config.ts";
|
||||
|
||||
/**
|
||||
@@ -36,7 +35,7 @@ import { AVATARS_DIR } from "../config.ts";
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
router.put("/api/me/avatar", authMiddleware, async (ctx) => {
|
||||
const authPayload = ctx.state.user;
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
@@ -50,19 +49,12 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// 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);
|
||||
// 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));
|
||||
assertLocalUser(getUserById(authPayload.userId));
|
||||
const user = await setUserAvatar(
|
||||
authPayload.userId,
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
await notifyClients(user.id, { type: "update" });
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(user),
|
||||
@@ -70,12 +62,10 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/avatars/:userId", async (ctx) => {
|
||||
const { userId } = ctx.params;
|
||||
|
||||
router.get("/api/users/:key/avatar", async (ctx) => {
|
||||
let user;
|
||||
try {
|
||||
user = getUserById(userId);
|
||||
user = resolveUserKey(ctx.params.key);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
@@ -86,7 +76,7 @@ router.get("/api/avatars/:userId", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime);
|
||||
await serveUploadedFile(ctx, `${AVATARS_DIR}/${user.id}`, user.avatarMime);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 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. Answers the page's
|
||||
* tracks and their stream URLs; `force=1` skips the server's cache.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* 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 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";
|
||||
|
||||
const responseBody: APIResponse<Tralbum> = {
|
||||
success: true,
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
export default bandcampRouter;
|
||||
@@ -1,39 +1,36 @@
|
||||
/**
|
||||
* The category routes.
|
||||
* Serves the categories dumps and playlists are filed under, a set for each
|
||||
* kind, and changes them for those with `category:manage`. A dump category's
|
||||
* feed is `GET /api/dumps?category=:slug`, a playlist category's
|
||||
* `GET /api/playlists?category=:slug`.
|
||||
*
|
||||
* - `GET /api/categories` — public. Answers every category, in order.
|
||||
* - `GET /api/categories/:slug` — public. Answers one category.
|
||||
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
|
||||
* a page of its dumps, newest first, with the caller's private ones.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category.
|
||||
* - `GET /api/categories` — public. Answers every category of both kinds, by
|
||||
* kind, then in order.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category
|
||||
* of the kind it names.
|
||||
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
|
||||
* category.
|
||||
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
|
||||
* category; its dumps stay.
|
||||
* category; its dumps or playlists stay.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
isCreateCategoryRequest,
|
||||
isUpdateCategoryRequest,
|
||||
} from "../validators/category.ts";
|
||||
|
||||
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
getCategoryBySlug,
|
||||
listCategories,
|
||||
listDumpsByCategory,
|
||||
updateCategory,
|
||||
} from "../services/category-service.ts";
|
||||
|
||||
@@ -51,31 +48,6 @@ router.get("/", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:slug", (ctx) => {
|
||||
const category = getCategoryBySlug(ctx.params.slug);
|
||||
const responseBody: APIResponse<Category> = {
|
||||
success: true,
|
||||
data: category,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:slug/dumps", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = listDumpsByCategory(
|
||||
ctx.params.slug,
|
||||
page,
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authMiddleware,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Serves the comments on dumps. Comments are broadcast over the WebSocket as
|
||||
* they are posted, edited and deleted, to everyone, or to its poster alone for
|
||||
* a private dump, whose comments 404 for anyone else.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
||||
* on a dump, oldest first.
|
||||
@@ -76,7 +76,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
body.body,
|
||||
body.parentId ?? undefined,
|
||||
);
|
||||
if (!dump.isPrivate) broadcastCommentCreated(comment);
|
||||
broadcastCommentCreated(comment, dump);
|
||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = responseBody;
|
||||
@@ -89,13 +89,13 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
if (!isUpdateCommentRequest(body)) {
|
||||
throw new APIException(400, "Invalid comment data");
|
||||
}
|
||||
const { comment, isPrivate } = updateComment(
|
||||
const { comment, dump } = updateComment(
|
||||
ctx.params.commentId,
|
||||
body.body,
|
||||
userId,
|
||||
canModerate,
|
||||
);
|
||||
if (!isPrivate) broadcastCommentUpdated(comment);
|
||||
broadcastCommentUpdated(comment, dump);
|
||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
@@ -123,12 +123,12 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
const { dumpId, isPrivate } = deleteComment(
|
||||
const dump = deleteComment(
|
||||
ctx.params.commentId,
|
||||
userId,
|
||||
canModerate,
|
||||
);
|
||||
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
|
||||
broadcastCommentDeleted(ctx.params.commentId, dump);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
50
api/routes/community.ts
Normal file
50
api/routes/community.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Serves the figures of the client's `/community` page, which
|
||||
* `community-service.ts` recomputes on a timer, and the community feed.
|
||||
*
|
||||
* - `GET /api/community?period=weekly|monthly` — signed in. Answers the
|
||||
* site's figures over the last week (by default) or month.
|
||||
* - `GET /api/community/feed` — signed in. Answers the latest things done in
|
||||
* public and the week's figures.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { CommunityFeed, CommunityStats } from "../../shared/community.ts";
|
||||
import { isDigestCadence } from "../../shared/settings.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
getCommunityFeed,
|
||||
getCommunityStats,
|
||||
} from "../services/community-service.ts";
|
||||
|
||||
/**
|
||||
* 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/community" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
const period = ctx.request.url.searchParams.get("period") ?? "weekly";
|
||||
if (!isDigestCadence(period)) {
|
||||
throw new APIException(400, "period must be weekly or monthly");
|
||||
}
|
||||
const responseBody: APIResponse<CommunityStats> = {
|
||||
success: true,
|
||||
data: getCommunityStats(period),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/feed", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<CommunityFeed> = {
|
||||
success: true,
|
||||
data: getCommunityFeed(),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +1,24 @@
|
||||
/**
|
||||
* 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.
|
||||
* Posts, reads, edits and deletes dumps, for the feeds, the dump page, its
|
||||
* edit page and the create form. `:dumpId` is a dump's id; the dump page reads
|
||||
* a dump by its path, through `GET /api/users/:key/dumps/:slug`. A private
|
||||
* dump answers 404 to anyone but its poster, moderators included. Its file and
|
||||
* thumbnail are served by `routes/files.ts` and `routes/thumbnails.ts`, its
|
||||
* comments by `routes/comments.ts`.
|
||||
*
|
||||
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
|
||||
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
|
||||
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
|
||||
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
|
||||
* dumps the caller may see, newest first.
|
||||
* - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in
|
||||
* the category with that slug; 404 when there is none.
|
||||
* - `GET /api/dumps?follow=users&category=&page=&limit=` — signed in. The
|
||||
* index's "follow" tab: a page of the public dumps of the users the caller
|
||||
* follows, newest first, optionally in one category.
|
||||
* - `GET /api/dumps?follow=playlists&category=&page=&limit=` — signed in. Its
|
||||
* `/follow/playlists` view: a page of the public dumps in the public
|
||||
* playlists the caller follows, most recently added first.
|
||||
* - `GET /api/dumps?url=` — optional sign-in. Answers the dumps already
|
||||
* pointing at the same thing as `url`, for the create form's duplicate hint.
|
||||
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
|
||||
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
|
||||
@@ -14,21 +27,12 @@
|
||||
* linked to or from it.
|
||||
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
|
||||
* playlists it appears in.
|
||||
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
|
||||
* dumps 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 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.
|
||||
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
|
||||
* MB).
|
||||
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Removes its custom thumbnail.
|
||||
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
|
||||
* with `dump:moderate`. Fetches a link's preview again.
|
||||
* `dump:moderate`. Updates its title, slug, comment, visibility, categories,
|
||||
* and a link's URL. A new slug keeps the old one as an alias, 3 times a day
|
||||
* at most but for those with `dump:moderate`.
|
||||
* - `POST /api/dumps/:dumpId/metadata` — signed in, as its poster or with
|
||||
* `dump:moderate`. Fetches a link's preview again.
|
||||
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
|
||||
* `dump:moderate`. Deletes it.
|
||||
*
|
||||
@@ -43,6 +47,7 @@ import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
|
||||
import {
|
||||
isCreateUrlDumpRequest,
|
||||
isUpdateDumpRequest,
|
||||
parseCategoryIds,
|
||||
} from "../validators/dump.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
@@ -51,9 +56,7 @@ import { toPublicUser } from "../model/user.ts";
|
||||
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 { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import { type Page, parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
createFileDump,
|
||||
createUrlDump,
|
||||
@@ -62,11 +65,17 @@ import {
|
||||
getDump,
|
||||
listDumps,
|
||||
refreshDumpMetadata,
|
||||
removeDumpThumbnail,
|
||||
replaceFileDump,
|
||||
setDumpThumbnail,
|
||||
updateDump,
|
||||
} from "../services/dump-service.ts";
|
||||
import {
|
||||
getCategoryBySlug,
|
||||
listDumpsByCategory,
|
||||
} from "../services/category-service.ts";
|
||||
import {
|
||||
getFollowedPlaylistsDumpFeed,
|
||||
getFollowedUsersDumpFeed,
|
||||
} from "../services/follow-service.ts";
|
||||
import { FOLLOW_VIEWS, type FollowView } from "../../shared/paths.ts";
|
||||
import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
@@ -77,24 +86,8 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* 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 [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Each handler resolves `:dumpId` with getDump, which checks privacy, before
|
||||
// acting on the dump.
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
@@ -140,17 +133,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Registered ahead of "/:dumpId" so the literal path wins over the parameter.
|
||||
router.get("/by-url", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const responseBody: APIResponse<DumpUrlMatch[]> = {
|
||||
success: true,
|
||||
data: findDumpsByUrl(url, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
@@ -194,8 +176,37 @@ router.get("/:dumpId/playlists", async (ctx) => {
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = listDumps(page, limit, requestingUserId);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const url = params.get("url");
|
||||
if (url !== null) {
|
||||
const responseBody: APIResponse<DumpUrlMatch[]> = {
|
||||
success: true,
|
||||
data: findDumpsByUrl(url, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
const { page, limit } = parsePagination(params);
|
||||
const category = params.get("category");
|
||||
const follow = params.get("follow");
|
||||
let feed: Page<Dump>;
|
||||
if (follow !== null) {
|
||||
if (!requestingUserId) throw new APIException(401, "Missing token");
|
||||
if (!(FOLLOW_VIEWS as readonly string[]).includes(follow)) {
|
||||
throw new APIException(400, "follow must be users or playlists");
|
||||
}
|
||||
const categoryId = category
|
||||
? getCategoryBySlug("dump", category).id
|
||||
: undefined;
|
||||
feed = (follow as FollowView) === "users"
|
||||
? getFollowedUsersDumpFeed(requestingUserId, page, limit, categoryId)
|
||||
: getFollowedPlaylistsDumpFeed(requestingUserId, page, limit, categoryId);
|
||||
} else {
|
||||
feed = category
|
||||
? listDumpsByCategory(category, page, limit, requestingUserId)
|
||||
: listDumps(page, limit, requestingUserId);
|
||||
}
|
||||
const { items, hasMore } = feed;
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
@@ -203,40 +214,6 @@ router.get("/", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
const title = formData.get("title");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
typeof title === "string" && title ? title : undefined,
|
||||
formData.has("categoryIds")
|
||||
? parseCategoryIds(formData.get("categoryIds"))
|
||||
: undefined,
|
||||
formData.has("isPrivate")
|
||||
? formData.get("isPrivate") === "true"
|
||||
: undefined,
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
@@ -252,54 +229,16 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await updateDump(dump.id, body);
|
||||
const updatedDump = await updateDump(
|
||||
dump.id,
|
||||
body,
|
||||
can(ctx.state.user, "dump:moderate"),
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
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(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// 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 responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await removeDumpThumbnail(dump.id);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
router.post("/:dumpId/metadata", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
152
api/routes/federation.ts
Normal file
152
api/routes/federation.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Lets users of other gerbeur instances sign in here with their account
|
||||
* there, as `federation-service.ts` describes, and lets admins pick the
|
||||
* instances to trust or block. The login and register pages start a sign-in,
|
||||
* the `/federation/callback` page finishes it, `/manage` edits the peers, and
|
||||
* the other instances' servers send their events.
|
||||
*
|
||||
* - `POST /api/federation/logins` — public. Starts a sign-in with an account
|
||||
* on another instance (`FederationLoginRequest`, with an invite unless it
|
||||
* is trusted), and answers its `/authorize` page to go to. Rate-limited per
|
||||
* instance.
|
||||
* - `POST /api/federation/sessions` — public. Finishes it
|
||||
* (`FederationSessionRequest`) and answers the user and their tokens, as a
|
||||
* sign-in does. Each sign-in started finishes once, so the limit on
|
||||
* `/logins` bounds it.
|
||||
* - `POST /api/federation/events` — public, server to server. Applies a
|
||||
* home instance's signed event about one of its users (`{ event }`).
|
||||
* Rate-limited per instance; a 4xx tells the sender to give up on the
|
||||
* event, a 429 or a 5xx to try again.
|
||||
* - `GET /api/federation/peers` — optional sign-in. Answers the trusted
|
||||
* instances, or every listed one to `federation:manage`.
|
||||
* - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or
|
||||
* blocks an instance (`SetPeerRequest`) and answers it; 502 when one to
|
||||
* trust doesn't answer as a gerbeur.
|
||||
* - `DELETE /api/federation/peers/:host` — permission `federation:manage`.
|
||||
* Takes an instance off the lists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import {
|
||||
type FederationLoginResponse,
|
||||
type FederationPeer,
|
||||
parseInstance,
|
||||
} from "../../shared/federation.ts";
|
||||
import type { AuthResponse } from "../../shared/user.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { issueTokens } from "../lib/jwt.ts";
|
||||
import { hasPermission } from "../../shared/permissions.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import {
|
||||
type AuthContext,
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.ts";
|
||||
import { toSelfUser } from "../model/user.ts";
|
||||
import {
|
||||
isFederationLoginRequest,
|
||||
isFederationSessionRequest,
|
||||
isSetPeerRequest,
|
||||
} from "../validators/federation.ts";
|
||||
import {
|
||||
completeLogin,
|
||||
receiveEvent,
|
||||
startLogin,
|
||||
} from "../services/federation-service.ts";
|
||||
import {
|
||||
listPeers,
|
||||
removePeer,
|
||||
setPeer,
|
||||
} from "../services/federation-peer-service.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { fieldOf } from "../validators/user.ts";
|
||||
|
||||
/**
|
||||
* Allows a burst of 30 sign-ins per instance, then one every 2 seconds, so
|
||||
* that nobody makes this instance hammer another.
|
||||
*/
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 30, refill: 1 / 2 };
|
||||
|
||||
/**
|
||||
* 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/federation" });
|
||||
|
||||
router.post("/logins", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isFederationLoginRequest(body)) {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
if (!takeToken(`federation:${parseInstance(body.instance)}`, LOGIN_LIMIT)) {
|
||||
throw new APIException(429, "Too many sign-ins — try again in a minute");
|
||||
}
|
||||
const responseBody: APIResponse<FederationLoginResponse> = {
|
||||
success: true,
|
||||
data: { authorizeUrl: await startLogin(body) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/sessions", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isFederationSessionRequest(body)) {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
const user = await completeLogin(body);
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/events", async (ctx) => {
|
||||
const event = fieldOf(await ctx.request.body.json(), "event");
|
||||
if (typeof event !== "string") throw new APIException(400, "Invalid event");
|
||||
await receiveEvent(event);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.get("/peers", async (ctx) => {
|
||||
const userId = await parseOptionalAuth(ctx);
|
||||
const all = !!userId &&
|
||||
hasPermission(getUserById(userId).role, "federation:manage");
|
||||
const responseBody: APIResponse<FederationPeer[]> = {
|
||||
success: true,
|
||||
data: listPeers(!all),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/peers",
|
||||
authMiddleware,
|
||||
requirePermission("federation:manage"),
|
||||
async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isSetPeerRequest(body)) throw new APIException(400, "Invalid request");
|
||||
const responseBody: APIResponse<FederationPeer> = {
|
||||
success: true,
|
||||
data: await setPeer(body.instance, body.status),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/peers/:host",
|
||||
authMiddleware,
|
||||
requirePermission("federation:manage"),
|
||||
(ctx: AuthContext<"/peers/:host">) => {
|
||||
removePeer(ctx.params.host);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,26 +1,35 @@
|
||||
/**
|
||||
* Serves file dumps' uploads to the players, viewers and download links of the
|
||||
* frontend, which builds the URL with `dumpFileUrl`.
|
||||
* Serves and replaces file dumps' uploads, a sub-resource of their dump. The
|
||||
* players, viewers and download links of the frontend build the URL with
|
||||
* `dumpFileUrl`, and its edit page replaces the file.
|
||||
*
|
||||
* - `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.
|
||||
* - `GET /api/dumps/:dumpId/file?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.
|
||||
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
|
||||
* title, slug, categories and visibility (multipart).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { parseCategoryIds } from "../validators/dump.ts";
|
||||
import { getDump, replaceFileDump } from "../services/dump-service.ts";
|
||||
|
||||
/**
|
||||
* 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" });
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
/**
|
||||
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
||||
@@ -54,19 +63,9 @@ function isInlineSafe(mime: string): boolean {
|
||||
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
router.get("/:dumpId/file", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// 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(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// 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);
|
||||
const { dump, cacheControl } = await resolveMediaDump(ctx, dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(404, "No file for this dump");
|
||||
@@ -88,21 +87,9 @@ router.get("/:dumpId", async (ctx) => {
|
||||
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
||||
);
|
||||
}
|
||||
// The URL may carry a media token; don't pass it on to wherever the file
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
|
||||
// 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);
|
||||
const served = await serveFile(ctx, path, {
|
||||
mime: dump.fileMime,
|
||||
cacheControl: current
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`,
|
||||
cacheControl,
|
||||
ranges: true,
|
||||
});
|
||||
if (!served) {
|
||||
@@ -110,4 +97,40 @@ router.get("/:dumpId", async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const canModerate = can(ctx.state.user, "dump:moderate");
|
||||
if (userId !== dump.userId && !canModerate) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
const title = formData.get("title");
|
||||
const slug = formData.get("slug");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
typeof title === "string" && title ? title : undefined,
|
||||
formData.has("categoryIds")
|
||||
? parseCategoryIds(formData.get("categoryIds"))
|
||||
: undefined,
|
||||
formData.has("isPrivate")
|
||||
? formData.get("isPrivate") === "true"
|
||||
: undefined,
|
||||
typeof slug === "string" && slug ? slug : undefined,
|
||||
canModerate,
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,118 +1,61 @@
|
||||
/**
|
||||
* 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`.
|
||||
* Serves what the caller follows, and follows or stops following for them.
|
||||
* The dumps of what they follow, the index's "follow" tab, are
|
||||
* `GET /api/dumps?follow=`'s (`routes/dumps.ts`), and what another user
|
||||
* follows is `routes/users.ts`'s.
|
||||
*
|
||||
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
|
||||
* - `GET /api/me/follow` — signed in. Answers the ids of the users and
|
||||
* playlists they follow.
|
||||
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
|
||||
* page of the public dumps of the users they follow, newest first, optionally
|
||||
* in one category.
|
||||
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
|
||||
* Answers a page of the public dumps in the public playlists they follow,
|
||||
* most recently added first, optionally in one category.
|
||||
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
|
||||
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
|
||||
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
|
||||
* playlist.
|
||||
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
|
||||
* - `PUT /api/me/follow/users/:userId` — signed in. Follows a user.
|
||||
* - `DELETE /api/me/follow/users/:userId` — signed in. Stops following a user.
|
||||
* - `PUT /api/me/follow/playlists/:playlistId` — signed in. Follows a public
|
||||
* playlist.
|
||||
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
|
||||
* a playlist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { FollowStatus } from "../../shared/follow.ts";
|
||||
import { type Page, parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
followPlaylist,
|
||||
followUser,
|
||||
getFollowedPlaylistsDumpFeed,
|
||||
getFollowedUsersDumpFeed,
|
||||
getFollowStatus,
|
||||
unfollowPlaylist,
|
||||
unfollowUser,
|
||||
} from "../services/follow-service.ts";
|
||||
import { getCategoryIdBySlug } from "../services/category-service.ts";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): string | "missing" | undefined {
|
||||
const slug = params.get("category");
|
||||
if (!slug) return undefined;
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
}
|
||||
|
||||
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
||||
|
||||
/**
|
||||
* 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" });
|
||||
const router: Router = new Router({ prefix: "/api/me" });
|
||||
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
router.get("/follow", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
: getFollowedPlaylistsDumpFeed(
|
||||
ctx.state.user.userId,
|
||||
page,
|
||||
limit,
|
||||
categoryId,
|
||||
);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.post("/users/:userId", authMiddleware, (ctx) => {
|
||||
router.put("/follow/users/:userId", authMiddleware, (ctx) => {
|
||||
followUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
||||
router.delete("/follow/users/:userId", authMiddleware, (ctx) => {
|
||||
unfollowUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
router.put("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
router.delete("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Serves the invite endpoints. Any signed-in user can invite someone, from
|
||||
* their profile page, and the invite link opens the `/register` page.
|
||||
* Lets any signed-in user invite someone, from their profile page. 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 that an invite still works,
|
||||
|
||||
198
api/routes/me.ts
Normal file
198
api/routes/me.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Serves the caller's own account: their user, settings, password and media
|
||||
* token, and which of their playlists hold a dump. What they follow, their
|
||||
* feed and their notifications are under `/api/me` too, served by
|
||||
* `routes/follows.ts` and `routes/notifications.ts`, and their avatar by
|
||||
* `routes/avatars.ts`.
|
||||
*
|
||||
* - `GET /api/me` — signed in. Answers the caller's own user.
|
||||
* - `PATCH /api/me` — signed in. Changes the caller's description and email,
|
||||
* and answers their user. The email of an account from another instance is
|
||||
* that instance's: 400. A username changes only by `user:manage`, through
|
||||
* `PATCH /api/users/:key/username`.
|
||||
* - `GET /api/me/media-token` — signed in. Answers a media token, for a
|
||||
* session that predates them.
|
||||
* - `GET /api/me/settings` — signed in. Answers the caller's settings.
|
||||
* - `PATCH /api/me/settings` — signed in. Changes some of the caller's
|
||||
* settings, and answers them all.
|
||||
* - `PUT /api/me/password` — signed in, with the current password. Sets a new
|
||||
* one, ending every other session, and answers fresh tokens. 400 for an
|
||||
* account from another instance, which has no password here.
|
||||
* - `GET /api/me/playlists?contains=:dumpId` — signed in. Answers each of the
|
||||
* caller's playlists and whether it holds the dump.
|
||||
* - `GET /api/me/grants` — signed in. Answers the other instances the caller
|
||||
* let their account sign in to.
|
||||
* - `DELETE /api/me/grants/:host` — signed in. Withdraws that grant, which
|
||||
* ends the caller's sessions there.
|
||||
*
|
||||
* A change to the caller's email or password is told to those instances
|
||||
* (`notifyClients`).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
AuthTokens,
|
||||
MediaTokenResponse,
|
||||
SelfUser,
|
||||
} from "../../shared/user.ts";
|
||||
import type { PlaylistMembership } from "../../shared/playlist.ts";
|
||||
import type { UserSettings } from "../../shared/settings.ts";
|
||||
import { fieldOf, isUpdateUserRequest } from "../validators/user.ts";
|
||||
import { isUpdateSettingsRequest } from "../validators/settings.ts";
|
||||
import { toPublicUser, toSelfUser } from "../model/user.ts";
|
||||
import { createMediaToken, issueTokens } from "../lib/jwt.ts";
|
||||
import { verifyPassword } from "../lib/password.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
assertLocalUser,
|
||||
getUserById,
|
||||
updateUser,
|
||||
} from "../services/user-service.ts";
|
||||
import { getSettings, updateSettings } from "../services/settings-service.ts";
|
||||
import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts";
|
||||
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import type { FederationGrant } from "../../shared/federation.ts";
|
||||
import {
|
||||
listGrants,
|
||||
notifyClients,
|
||||
revokeGrant,
|
||||
} from "../services/identity-provider-service.ts";
|
||||
|
||||
/**
|
||||
* 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/me" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch("/", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateUserRequest(body)) {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
const userId = ctx.state.user.userId;
|
||||
if (body.email !== undefined) assertLocalUser(getUserById(userId));
|
||||
// Only these fields: a stolen token alone must not change the role or the
|
||||
// password, which needs the current one.
|
||||
const updated = await updateUser(userId, {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
});
|
||||
broadcastUserUpdated(toPublicUser(updated));
|
||||
if (body.email !== undefined) await notifyClients(userId, { type: "update" });
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(updated),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||||
success: true,
|
||||
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/settings", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<UserSettings> = {
|
||||
success: true,
|
||||
data: getSettings(ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch("/settings", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateSettingsRequest(body)) {
|
||||
throw new APIException(400, "Invalid settings");
|
||||
}
|
||||
const responseBody: APIResponse<UserSettings> = {
|
||||
success: true,
|
||||
data: updateSettings(ctx.state.user.userId, body),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const currentPassword = fieldOf(body, "currentPassword");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
|
||||
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
|
||||
if (
|
||||
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
||||
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||
) {
|
||||
throw new APIException(
|
||||
400,
|
||||
`Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = getUserById(ctx.state.user.userId);
|
||||
assertLocalUser(user);
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash ?? "");
|
||||
if (!valid) {
|
||||
throw new APIException(403, "Current password is incorrect");
|
||||
}
|
||||
|
||||
// Changing the password revokes every token issued before it, this
|
||||
// session's included, so hand back fresh ones for the client to keep.
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
password: newPassword,
|
||||
});
|
||||
await notifyClients(updated.id, { type: "revoke" });
|
||||
const responseBody: APIResponse<AuthTokens> = {
|
||||
success: true,
|
||||
data: await issueTokens(updated),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/playlists", authMiddleware, (ctx: AuthContext) => {
|
||||
const dumpId = ctx.request.url.searchParams.get("contains");
|
||||
if (!dumpId) throw new APIException(400, "contains is required");
|
||||
const responseBody: APIResponse<PlaylistMembership[]> = {
|
||||
success: true,
|
||||
data: getPlaylistMembershipsForDump(dumpId, ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/grants", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<FederationGrant[]> = {
|
||||
success: true,
|
||||
data: listGrants(ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.delete(
|
||||
"/grants/:host",
|
||||
authMiddleware,
|
||||
async (ctx: AuthContext<"/grants/:host">) => {
|
||||
await revokeGrant(ctx.state.user.userId, ctx.params.host);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -3,9 +3,9 @@
|
||||
* 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.
|
||||
* - `GET /api/me/notifications?page=&limit=` — signed in. Answers a page of
|
||||
* them, newest first.
|
||||
* - `POST /api/me/notifications/read` — signed in. Marks them all read.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
* 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" });
|
||||
const router: Router = new Router({ prefix: "/api/me/notifications" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -40,7 +40,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
router.post("/read", authMiddleware, (ctx: AuthContext) => {
|
||||
markAllRead(ctx.state.user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
78
api/routes/oauth.ts
Normal file
78
api/routes/oauth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Lets this instance's users sign in to other gerbeur instances with their
|
||||
* account here, as `identity-provider-service.ts` describes. The client's
|
||||
* `/authorize` page calls the first two; the other instance's server calls the
|
||||
* last.
|
||||
*
|
||||
* - `GET /api/oauth/clients?client_id=` — signed in. Answers the instance
|
||||
* asking to sign the caller in (`OAuthClient`), and whether they already
|
||||
* let it; 400 for an account from another instance, 403 when that instance
|
||||
* is blocked here, 502 when it doesn't answer as a gerbeur.
|
||||
* - `POST /api/oauth/codes` — signed in. Records the caller's consent to the
|
||||
* request (`AuthorizeRequest`) and answers where to send them, the
|
||||
* instance's callback page with a code.
|
||||
* - `POST /api/oauth/tokens` — public, form-encoded, server to server. Trades
|
||||
* a code and its PKCE verifier for an identity token (`id_token`), bare
|
||||
* JSON as OAuth has it; 400 when anything doesn't match. Rate-limited per
|
||||
* client.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type {
|
||||
AuthorizeResponse,
|
||||
OAuthClient,
|
||||
} from "../../shared/federation.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { isAuthorizeRequest } from "../validators/federation.ts";
|
||||
import {
|
||||
createAuthorizationCode,
|
||||
describeClient,
|
||||
exchangeCode,
|
||||
} from "../services/identity-provider-service.ts";
|
||||
|
||||
/** Allows a burst of 30 code trades per client instance, then one a second. */
|
||||
const TOKEN_LIMIT: RateLimit = { capacity: 30, refill: 1 };
|
||||
|
||||
/**
|
||||
* 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/oauth" });
|
||||
|
||||
router.get("/clients", authMiddleware, async (ctx: AuthContext) => {
|
||||
const clientId = ctx.request.url.searchParams.get("client_id");
|
||||
if (!clientId) throw new APIException(400, "client_id is required");
|
||||
const responseBody: APIResponse<OAuthClient> = {
|
||||
success: true,
|
||||
data: await describeClient(ctx.state.user.userId, clientId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/codes", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isAuthorizeRequest(body)) throw new APIException(400, "Invalid request");
|
||||
const responseBody: APIResponse<AuthorizeResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
redirectTo: await createAuthorizationCode(ctx.state.user.userId, body),
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/tokens", async (ctx) => {
|
||||
const params = new URLSearchParams(await ctx.request.body.text());
|
||||
if (!takeToken(`oauth-token:${params.get("client_id")}`, TOKEN_LIMIT)) {
|
||||
throw new APIException(429, "Too many requests");
|
||||
}
|
||||
ctx.response.headers.set("Cache-Control", "no-store");
|
||||
ctx.response.body = await exchangeCode(params);
|
||||
});
|
||||
|
||||
export default router;
|
||||
59
api/routes/password-resets.ts
Normal file
59
api/routes/password-resets.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Resets forgotten passwords. The client's `/login` page asks for a reset
|
||||
* link, which the email takes to the `/reset-password` page, whose token
|
||||
* names the reset.
|
||||
*
|
||||
* - `POST /api/password-resets` — public. Emails a reset link to the account
|
||||
* with that `email`, if any; always answers 200.
|
||||
* - `PUT /api/password-resets/:token` — public, with a reset token. Sets the
|
||||
* `newPassword`, which ends the user's sessions here and on the instances
|
||||
* they signed in to.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { fieldOf } from "../validators/user.ts";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} from "../services/password-reset-service.ts";
|
||||
import { notifyClients } from "../services/identity-provider-service.ts";
|
||||
|
||||
/**
|
||||
* 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/password-resets" });
|
||||
|
||||
// Answers the same whatever happens, so it can't tell which addresses have an
|
||||
// account.
|
||||
router.post("/", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
const trimmed = typeof email === "string" ? email.trim() : "";
|
||||
if (trimmed) {
|
||||
await requestPasswordReset(trimmed).catch((err) =>
|
||||
console.error("[password-resets]", err)
|
||||
);
|
||||
}
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:token", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
if (typeof newPassword !== "string") {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
const userId = await resetPassword(ctx.params.token, newPassword);
|
||||
await notifyClients(userId, { type: "revoke" });
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,45 +1,55 @@
|
||||
/**
|
||||
* Serves playlists, their dumps and their covers. `:playlistId` takes an id or
|
||||
* a slug.
|
||||
* Serves playlists, their dumps and their covers. `:playlistId` is a
|
||||
* playlist's id; its page reads it by its path, through
|
||||
* `GET /api/users/:key/playlists/: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?category=&sort=&page=&limit=` — public. Answers a
|
||||
* page of every public playlist, in the order `sort` names
|
||||
* (`PLAYLIST_SORTS`, the last to take a dump first by default), or only
|
||||
* those in the playlist category at `category`, which 404s when there is
|
||||
* none.
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||
* playlist categories it names.
|
||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||
* 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.
|
||||
* `playlist:moderate`. Updates its title, slug, description, visibility and
|
||||
* categories.
|
||||
* A new slug keeps the old one as an alias, 3 times a day at most but for
|
||||
* those with `playlist:moderate`.
|
||||
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Deletes it.
|
||||
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Adds a dump at the top.
|
||||
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Removes a dump.
|
||||
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
|
||||
* - `PUT /api/playlists/:playlistId/cover` — 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, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
|
||||
* without one.
|
||||
* - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, scaled
|
||||
* down to `w` (`shared/image.ts`) when it names one, 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.
|
||||
*
|
||||
* Which of the caller's playlists hold a dump is `GET /api/me/playlists`'s.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
isCreatePlaylistRequest,
|
||||
isReorderPlaylistRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
} from "../validators/playlist.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
import {
|
||||
parsePlaylistSort,
|
||||
type Playlist,
|
||||
type PlaylistWithDumps,
|
||||
} from "../../shared/playlist.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
@@ -50,7 +60,7 @@ import {
|
||||
getEditablePlaylist,
|
||||
getPlaylist,
|
||||
getPlaylistImageInfo,
|
||||
getPlaylistMembershipsForDump,
|
||||
listPublicPlaylists,
|
||||
removeDumpFromPlaylist,
|
||||
reorderPlaylist,
|
||||
setPlaylistImage,
|
||||
@@ -62,6 +72,8 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { PLAYLIST_IMAGES_DIR } from "../config.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { getCategoryBySlug } from "../services/category-service.ts";
|
||||
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
@@ -71,13 +83,22 @@ const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
|
||||
// Before /:playlistId, which would otherwise match "by-dump".
|
||||
router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const userId = ctx.state.user.userId;
|
||||
const responseBody: APIResponse<PlaylistMembership[]> = {
|
||||
router.get("/", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const slug = params.get("category");
|
||||
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
|
||||
const { items, hasMore } = listPublicPlaylists(
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
parsePlaylistSort(params.get("sort")),
|
||||
categoryId,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: getPlaylistMembershipsForDump(dumpId, userId),
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
@@ -148,7 +169,7 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
router.put("/:playlistId/cover", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(400, "Expected multipart/form-data");
|
||||
@@ -182,7 +203,7 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:playlistId/image", async (ctx) => {
|
||||
router.get("/:playlistId/cover", async (ctx) => {
|
||||
// An <img> can't send an Authorization header, so a private playlist's owner
|
||||
// is known by the media token in the query string, as for dump files.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user