v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-06 16:08:59 +00:00
parent fd0d81fe66
commit 4468438ba7
228 changed files with 7320 additions and 5193 deletions

202
API.md Normal file
View File

@@ -0,0 +1,202 @@
# 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. 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.
**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`; the bare `/` is the viewer's default tab | anyone, `/follow` signed in | `Index` |
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow` | anyone | `Index` |
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | | 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` |
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` |
| `/settings` | | signed in | `Settings` |
| `/manage` | | `category:manage` | `Manage` |
| `/login`, `/register?token=` | | signed out | `UserLogin`, `UserRegister` |
| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` |
`/?share_url=` opens the create form on a link shared from Android,
`/?chat=open` opens the chat over any page, and `#comment-<id>` scrolls a dump's
page to a comment.
## 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.
A user may rename themselves once every 30 days from `/settings`; those
with `user:manage` rename anyone, at any time, from `/@<name>/manage`.
- **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 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, cover or attachment takes the owner's media token as `?token=`.
### me
- `GET /api/me` — signed in. The caller's own user.
- `PATCH /api/me` — signed in. Changes the description, email and username.
- `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/feed?from=users|playlists&category=` — signed in. The dumps of
what the caller follows.
- `GET /api/me/notifications` — signed in. The caller's notifications.
- `POST /api/me/notifications/read` — signed in. Marks them all read.
### 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.
- `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.
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
role, or anyone's name.
### 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/: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`, `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.
- `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; 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.
- `POST /api/categories`, `PUT`, `DELETE /api/categories/:id` —
`category:manage`. Changes them.
### chat
- `GET /api/chat?before=&after=` — signed in. A page of messages. The chat
itself goes over `/ws`.
### 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.
### search
- `GET /api/search?q=` — anyone. A page of dumps, and up to 20 users and
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`).

View File

@@ -131,7 +131,7 @@ api/
main.ts # Entry point — Oak application, middleware, routes main.ts # Entry point — Oak application, middleware, routes
config.ts # Environment variables config.ts # Environment variables
middleware/ # Oak middleware: errors, auth, OG tags 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 services/ # Business logic and every SQL query
remote/ # Everything that fetches from other sites remote/ # Everything that fetches from other sites
mail/ # One template per email, and the layout they share mail/ # One template per email, and the layout they share
@@ -150,7 +150,7 @@ api/
gerbeur.db # SQLite database (not committed) gerbeur.db # SQLite database (not committed)
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/ # React frontend (Vite) src/ # React frontend (Vite)
config/ # API base URL, feed tabs, player mode, instance settings config/ # API base URL, default feed tab, player mode, instance settings
pages/ # One component per route, nothing else pages/ # One component per route, nothing else
components/ # React components only (.tsx) components/ # React components only (.tsx)
contexts/ # FooContext.ts + FooProvider.tsx pairs contexts/ # FooContext.ts + FooProvider.tsx pairs
@@ -162,6 +162,16 @@ src/ # React frontend (Vite)
public/ # Static assets (favicon, manifest, service worker) 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 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/api.ts`), each domain's types and request bodies (`shared/dump.ts`,
`shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation `shared/user.ts`, …), the socket protocol (`shared/ws.ts`), the validation

View File

@@ -27,6 +27,7 @@ import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensi
import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.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 up0019UserSettings } from "./migrations/0019_user_settings.ts";
import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts"; import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts";
import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -66,6 +67,7 @@ const MIGRATIONS: Migration[] = [
}, },
{ name: "0019_user_settings", up: up0019UserSettings }, { name: "0019_user_settings", up: up0019UserSettings },
{ name: "0020_digest_schedule", up: up0020DigestSchedule }, { name: "0020_digest_schedule", up: up0020DigestSchedule },
{ name: "0021_page_urls", up: up0021PageUrls },
]; ];
/** /**

View File

@@ -0,0 +1,262 @@
/**
* 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 { isReservedSlug } 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;
}
/**
* Renames each category whose slug v4 reserves at the root, such as `hot`,
* to the first of `<slug>-2`, `<slug>-3`… that is free, and logs it.
*/
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}": v4 reserves its slug`,
);
}
}
/** 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);
}

View File

@@ -121,12 +121,12 @@ CREATE TABLE dump_backlinks (
CREATE INDEX idx_dumps_created ON dumps(created_at); 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_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 ON dumps(url);
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical); 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_votes_user_created ON votes(user_id, created_at);
CREATE INDEX idx_playlists_user ON playlists(user_id); 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_order ON playlist_dumps(playlist_id, position);
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
@@ -231,6 +231,33 @@ CREATE TABLE digest_schedule (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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 ( CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,

View File

@@ -19,6 +19,7 @@ import {
} from "../model/auth.ts"; } from "../model/auth.ts";
import { JWT_SECRET } from "../config.ts"; import { JWT_SECRET } from "../config.ts";
import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts"; import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts";
import type { AuthTokens, Role } from "../../shared/user.ts";
if (!JWT_SECRET) { if (!JWT_SECRET) {
throw new Error( throw new Error(
@@ -61,9 +62,9 @@ export async function verifyPasswordResetToken(
} }
/** /**
* Signs a session token carrying `payload`, valid for 7 days. The users routes * Signs a session token carrying `payload`, valid for 7 days.
* issue it on sign-up, sign-in and password change, and the client sends it as * {@link issueTokens} issues it on sign-up, sign-in and password change, and
* a bearer token. * the client sends it as a bearer token.
*/ */
export async function createJWT( export async function createJWT(
payload: Omit<AuthPayload, "exp">, payload: Omit<AuthPayload, "exp">,
@@ -77,6 +78,24 @@ export async function createJWT(
.sign(JWT_KEY); .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 * 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 * an `Authorization` header, so the URLs of private files, thumbnails and

View File

@@ -1,29 +1,33 @@
/** /**
* Checks which slugs a category may take. A category's feed lives at the URL * 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 * root (`/music`), so its slug must not shadow another top-level page. The
* category service runs these checks when a category is created or renamed. * category service runs this check when a category is created or renamed, and
* migration 0021 renamed the categories it newly reserved.
*
* 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 * @module
*/ */
import { FEED_TABS } from "../../shared/paths.ts";
/** /**
* Lists the top-level path segments a category slug may not take. They are * Lists the top-level path segments a category slug may not take: the feed
* the SPA's own routes (`src/App.tsx`); `api`, `ws`, `assets` and * tabs (`/hot`), the SPA's own pages (`src/App.tsx`), and `api`, `ws`,
* `emojibase`, which the server answers before the SPA; and `manage`, a * `assets` and `emojibase`, which the server answers before the SPA. Keep it
* former route whose old links may still be about. Keep it in step with the * in step with the route table and the build's top-level folders.
* route table and the build's top-level folders.
*/ */
export const RESERVED_SLUGS: readonly string[] = [ export const RESERVED_SLUGS: readonly string[] = [
"~", ...FEED_TABS,
"dumps",
"users",
"playlists",
"search", "search",
"login", "login",
"register", "register",
"notifications", "notifications",
"reset-password", "reset-password",
"unsubscribe", "unsubscribe",
"settings",
"manage", "manage",
"api", "api",
"ws", "ws",
@@ -31,17 +35,6 @@ export const RESERVED_SLUGS: readonly string[] = [
"emojibase", "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}. */ /** Checks whether `slug` is one of the {@link RESERVED_SLUGS}. */
export function isReservedSlug(slug: string): boolean { export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug); return RESERVED_SLUGS.includes(slug);

View File

@@ -1,12 +1,14 @@
/** /**
* Recognises links to this site's own pages. The self provider previews them * 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 * from the database, remote/fetch.ts accepts them as dump links even on a
* private host such as localhost. * private host such as localhost, and migration 0021 rewrote the v3 ones in
* stored text.
* *
* @module * @module
*/ */
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts"; import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
import { parsePagePath } from "../../shared/paths.ts";
const SELF_HOSTS = new Set( const SELF_HOSTS = new Set(
[PUBLIC_URL, FRONTEND_URL].flatMap((u) => { [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 * 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 * pages, by `parsePagePath`; a dump's editor doesn't count. Matched on the
* site is someone else's page, not ours. * host as well as the path: `/@bob` on some other site is someone else's
* page, not ours.
*/ */
export function isSelfUrl(url: URL): boolean { export function isSelfUrl(url: URL): boolean {
return SELF_HOSTS.has(url.host.toLowerCase()) && if (!isSelfHost(url.host)) return false;
SELF_PATH_RE.test(url.pathname); const page = parsePagePath(url.pathname);
return !!page && !(page.kind === "dump" && page.edit);
} }

View File

@@ -1,38 +1,85 @@
/** /**
* Builds the URL slugs of dumps and playlists from their titles. The dump and * Picks and checks the slugs of dumps and playlists, which name them in their
* playlist services store the slug when the record is created or retitled. * 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 * @module
*/ */
/** import type { DatabaseSync } from "node:sqlite";
* Matches a UUID. A dump or playlist route takes either an id or a slug, and import { APIException } from "./api-exception.ts";
* the services test the parameter with this to know which column to look up. import { isValidSlug, SLUG_MAX } from "../../shared/slug.ts";
*/
export const UUID_RE = /** A kind of item that has a slug under its owner. */
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 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 * Returns the id of the item whose current slug or alias is `slug` in
* characters long. Accents are dropped, and a title with no ASCII letter or * `userId`'s space for `kind`, or undefined when it is free.
* digit gives `untitled`.
*/ */
export function slugify(title: string): string { export function slugHolder(
const slug = title db: DatabaseSync,
.toLowerCase() kind: SlugKind,
.normalize("NFD") userId: string,
// NFD splits an accented letter into the letter and a combining mark. slug: string,
.replace(/[\u0300-\u036f]/g, "") ): string | undefined {
.replace(/[^a-z0-9]+/g, "-") const { items, aliases, itemId } = SLUG_TABLES[kind];
.replace(/^-+|-+$/g, "") const row = db.prepare(
.substring(0, 60); `SELECT id FROM ${items} WHERE user_id = ? AND slug = ?
return slug || "untitled"; 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 * Returns `base`, or `base-2`, `base-3`… cut to fit, whichever is free in
* characters of `id`. The suffix keeps two records with the same title apart. * `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 { export function uniqueSlug(
return `${slugify(title)}-${id.substring(0, 8)}`; 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 `SLUG_MAX` long. Dump and playlist slugs
* reserve no word: nothing else a user names lives beside them.
*/
export function validateSlug(slug: string): void {
if (!isValidSlug(slug)) {
throw new APIException(
400,
`Slug must be lowercase letters, numbers and hyphens, at most ${SLUG_MAX} characters`,
);
}
} }

View File

@@ -62,8 +62,11 @@ export interface DigestHighlight {
kind: HighlightKind; kind: HighlightKind;
/** Who did it. */ /** Who did it. */
who: string; who: string;
/** What they did it on, absent for a follow of the reader or the chat. */ /**
target?: { title: string; url: 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. */ /** The comment or message, flattened and shortened. */
quote?: string; quote?: string;
at: Date; at: Date;
@@ -244,8 +247,10 @@ export function renderDigestEmail(
i18n._("email.digest.theChat"), i18n._("email.digest.theChat"),
content.links.chat, content.links.chat,
) )
: h.target : h.target?.url
? mdLink(h.target.title, h.target.url) ? mdLink(h.target.title, h.target.url)
: h.target
? escMd(h.target.title)
: ""; : "";
const line = `- ${ const line = `- ${
highlightSentence(h.kind, `**@${escMd(h.who)}**`, target) highlightSentence(h.kind, `**@${escMd(h.who)}**`, target)

View File

@@ -1,8 +1,9 @@
/** /**
* Builds the Oak application and, when run directly, starts the digests' * Builds the Oak application and, when run directly, starts the digests'
* scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes through the error * scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes
* middleware, the OG tags and CORS, then the routers, and last the built * through the error middleware, the OG tags and CORS, then the routers, one
* frontend, which answers every path no router did. * per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
* *
* @module * @module
*/ */
@@ -11,23 +12,25 @@ import { Application } from "@oak/oak";
import { oakCors } from "@tajpouria/cors"; import { oakCors } from "@tajpouria/cors";
import attachmentsRouter from "./routes/attachments.ts"; 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 commentsRouter from "./routes/comments.ts";
import dumpsRouter from "./routes/dumps.ts"; import dumpsRouter from "./routes/dumps.ts";
import filesRouter from "./routes/files.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 followsRouter from "./routes/follows.ts"; import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts"; import invitesRouter from "./routes/invites.ts";
import meRouter from "./routes/me.ts";
import notificationsRouter from "./routes/notifications.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 searchRouter from "./routes/search.ts";
import categoriesRouter from "./routes/categories.ts"; import sessionsRouter from "./routes/sessions.ts";
import thumbnailsRouter from "./routes/thumbnails.ts";
import unsubscribeRouter from "./routes/unsubscribe.ts";
import usersRouter from "./routes/users.ts";
import wsRouter from "./routes/ws.ts";
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts"; import { errorMiddleware } from "./middleware/error.ts";
@@ -55,78 +58,32 @@ app.use(async (ctx, next) => {
} }
await next(); await next();
}); });
app.use( for (
dumpsRouter.routes(), const router of [
dumpsRouter.allowedMethods(), meRouter,
); followsRouter,
app.use( notificationsRouter,
filesRouter.routes(), avatarsRouter,
filesRouter.allowedMethods(), usersRouter,
); sessionsRouter,
app.use( passwordResetsRouter,
thumbnailsRouter.routes(), unsubscribeRouter,
thumbnailsRouter.allowedMethods(), dumpsRouter,
); filesRouter,
app.use( thumbnailsRouter,
attachmentsRouter.routes(), commentsRouter,
attachmentsRouter.allowedMethods(), playlistsRouter,
); categoriesRouter,
app.use( searchRouter,
usersRouter.routes(), chatRouter,
usersRouter.allowedMethods(), invitesRouter,
); attachmentsRouter,
app.use( remoteRouter,
avatarsRouter.routes(), wsRouter,
avatarsRouter.allowedMethods(), ]
); ) {
app.use( app.use(router.routes(), router.allowedMethods());
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(),
);
app.use(routeStaticFilesFrom([ app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`, `${Deno.cwd()}/dist`,
`${Deno.cwd()}/public`, `${Deno.cwd()}/public`,

View File

@@ -28,7 +28,7 @@ export interface AuthState extends State {
/** /**
* The context type of a handler behind {@link authMiddleware}. Pass the route * 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< export type AuthContext<
R extends string = string, R extends string = string,

View File

@@ -2,15 +2,22 @@
* Gives links to the site rich previews in chat apps and social sites, which * 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 * 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 * playlist with `index.html` carrying that page's Open Graph and Twitter card
* tags. main.ts installs it before the routers. * tags, and a link by a former name with a 301 to the current one. main.ts
* installs it before the routers.
* *
* @module * @module
*/ */
import { Context, Next } from "@oak/oak"; import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts"; import { getDumpByPath } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts"; import { resolveUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-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 { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
import { loadIndexHtml } from "../lib/index-html.ts"; import { loadIndexHtml } from "../lib/index-html.ts";
@@ -83,17 +90,23 @@ async function findIndexHtml(): Promise<string | null> {
return null; return null;
} }
const DUMP_RE = /^\/dumps\/([^/]+)$/; /** Answers `canonical` with a 301, keeping the request's query string. */
/** Matches a profile, and its tabs under `/~/<tab>`. */ function redirect(ctx: Context, canonical: string): void {
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; ctx.response.status = 301;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; // 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 * 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 * path names (`parsePagePath`), and passes the request on when it names none
* the visitor may see. The page's URL is on `FRONTEND_URL` and the images' on * that exists or the visitor may see. A path naming one by a former name, a
* `PUBLIC_URL`, not on the request's origin, which a reverse proxy may have * user's or a slug, or in another case, gets a 301 to its current path. The
* rewritten to an internal host or plain http. * 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( export async function ogMiddleware(
ctx: Context, ctx: Context,
@@ -105,21 +118,21 @@ export async function ogMiddleware(
return await next(); return await next();
} }
const page = parsePagePath(pathname);
if (!page || (page.kind === "dump" && page.edit)) return await next();
const origin = PUBLIC_URL; const origin = PUBLIC_URL;
const pageUrl = `${FRONTEND_URL}${pathname}`;
let meta: OGMeta | null = null; let meta: OGMeta | null = null;
let canonical: string | null = null;
const dumpMatch = pathname.match(DUMP_RE); try {
const userMatch = pathname.match(USER_RE); const user = resolveUsername(page.username);
const playlistMatch = pathname.match(PLAYLIST_RE); if (page.kind === "dump") {
const dump = getDumpByPath(user.id, page.slug);
if (dumpMatch) { canonical = dumpPath(dump);
try {
const dump = getDump(dumpMatch[1]);
let imageUrl: string | undefined; let imageUrl: string | undefined;
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { 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) { } else if (dump.richContent?.thumbnailUrl) {
imageUrl = dump.richContent.thumbnailUrl; imageUrl = dump.richContent.thumbnailUrl;
} }
@@ -130,41 +143,41 @@ export async function ogMiddleware(
description: dump.comment, description: dump.comment,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
imageIsWide: !!imageUrl, imageIsWide: !!imageUrl,
url: pageUrl, url: `${FRONTEND_URL}${canonical}`,
}; };
} catch { /* Unknown, or private. */ } } else if (page.kind === "user") {
} else if (userMatch) { canonical = [userPath(user.username), ...page.rest].join("/");
try {
const user = getUserByUsername(userMatch[1]);
const imageUrl = user.avatarMime const imageUrl = user.avatarMime
? `${origin}/api/avatars/${user.id}` ? `${origin}/api/users/${user.id}/avatar`
: undefined; : undefined;
meta = { meta = {
title: user.username, title: user.username,
description: user.description, description: user.description,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl, url: `${FRONTEND_URL}${canonical}`,
}; };
} catch { /* Unknown. */ } } else {
} else if (playlistMatch) { const playlist = getPlaylistByPath(user.id, page.slug);
try { // getPlaylistByPath doesn't check visibility, unlike getDumpByPath.
const playlist = getPlaylistById(playlistMatch[1]);
// getPlaylistById doesn't check visibility, unlike getDump.
if (playlist.isPublic) { if (playlist.isPublic) {
canonical = playlistPath(playlist);
const imageUrl = playlist.imageMime const imageUrl = playlist.imageMime
? `${origin}/api/playlists/${playlist.id}/image` ? `${origin}/api/playlists/${playlist.id}/cover`
: undefined; : undefined;
meta = { meta = {
title: playlist.title, title: playlist.title,
description: playlist.description, description: playlist.description,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, 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(); const html = await findIndexHtml();
if (!html) return await next(); if (!html) return await next();

View File

@@ -23,7 +23,8 @@ export interface DumpRow {
user_id: string; user_id: string;
created_at: string; created_at: string;
updated_at: string | null; 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. */ /** The link as the poster submitted it. */
url: string | null; url: string | null;
/** {@link RichContent} as JSON. */ /** {@link RichContent} as JSON. */
@@ -34,6 +35,8 @@ export interface DumpRow {
vote_count: number; vote_count: number;
/** Computed by a subquery, which counts the comments that aren't deleted. */ /** Computed by a subquery, which counts the comments that aren't deleted. */
comment_count: number; comment_count: number;
/** The poster's current name, computed by a subquery. */
owner_username: string;
is_private: number; is_private: number;
custom_thumbnail_mime: string | null; custom_thumbnail_mime: string | null;
/** Lets a query select more columns, such as a join's. */ /** Lets a query select more columns, such as a join's. */
@@ -53,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 * 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}, 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 * Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to
* join it with other tables. * join it with other tables.
*/ */
export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED}, 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 * Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
@@ -81,7 +86,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
"created_at" in obj && typeof obj.created_at === "string" && "created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj && "updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) && (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) && "url" in obj && (typeof obj.url === "string" || obj.url === null) &&
"rich_content" in obj && "rich_content" in obj &&
(typeof obj.rich_content === "string" || obj.rich_content === null) && (typeof obj.rich_content === "string" || obj.rich_content === null) &&
@@ -93,6 +98,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
(typeof obj.file_size === "number" || obj.file_size === null) && (typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" && "vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_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" && "is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj && "custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" || (typeof obj.custom_thumbnail_mime === "string" ||
@@ -108,10 +114,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
id: row.id, id: row.id,
kind: row.kind as "url" | "file", kind: row.kind as "url" | "file",
title: row.title, title: row.title,
slug: row.slug ?? undefined, slug: row.slug,
url: row.url ?? undefined, url: row.url ?? undefined,
comment: row.comment ?? undefined, comment: row.comment ?? undefined,
userId: row.user_id, userId: row.user_id,
ownerUsername: row.owner_username,
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
richContent: row.rich_content richContent: row.rich_content
@@ -136,7 +143,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
id: dump.id, id: dump.id,
kind: dump.kind, kind: dump.kind,
title: dump.title, title: dump.title,
slug: dump.slug ?? null, slug: dump.slug,
comment: dump.comment ?? null, comment: dump.comment ?? null,
user_id: dump.userId, user_id: dump.userId,
created_at: dump.createdAt.toISOString(), created_at: dump.createdAt.toISOString(),
@@ -148,6 +155,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
file_size: dump.fileSize ?? null, file_size: dump.fileSize ?? null,
vote_count: dump.voteCount, vote_count: dump.voteCount,
comment_count: dump.commentCount, comment_count: dump.commentCount,
owner_username: dump.ownerUsername,
is_private: dump.isPrivate ? 1 : 0, is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null, custom_thumbnail_mime: dump.thumbnailMime ?? null,
}; };

View File

@@ -1,6 +1,6 @@
/** /**
* Turns the rows of `notifications`, once checked, into the {@link * Turns the rows of `notifications`, once checked, into the {@link
* Notification} that `GET /api/notifications` answers. * Notification} that `GET /api/me/notifications` answers.
* *
* @module * @module
*/ */

View File

@@ -9,15 +9,18 @@ import type { SQLOutputValue } from "node:sqlite";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
/** /**
* Returned by the playlist and follow services' `SELECT`s on `playlists`. A * Returned by the playlist and follow services' `SELECT`s on `playlists`,
* query may add `dump_count` and `owner_username` columns, which * which join the owner's name as `owner_username`. A query may add a
* {@link playlistRowToApi} carries over when present. * `dump_count` column, which {@link playlistRowToApi} carries over when
* present.
*/ */
export interface PlaylistRow { export interface PlaylistRow {
id: string; id: string;
user_id: string; user_id: string;
title: 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; description: string | null;
is_public: number; is_public: number;
created_at: string; created_at: string;
@@ -34,7 +37,8 @@ export function isPlaylistRow(
"id" in obj && typeof obj.id === "string" && "id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" && "user_id" in obj && typeof obj.user_id === "string" &&
"title" in obj && typeof obj.title === "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 && "description" in obj &&
(typeof obj.description === "string" || obj.description === null) && (typeof obj.description === "string" || obj.description === null) &&
"is_public" in obj && typeof obj.is_public === "number" && "is_public" in obj && typeof obj.is_public === "number" &&
@@ -51,15 +55,13 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
title: row.title, title: row.title,
slug: row.slug ?? undefined, slug: row.slug,
description: row.description ?? undefined, description: row.description ?? undefined,
isPublic: Boolean(row.is_public), isPublic: Boolean(row.is_public),
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined, imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string" ownerUsername: row.owner_username,
? row.owner_username
: undefined,
}; };
} }

View File

@@ -1,6 +1,6 @@
/** /**
* Resolves a Bandcamp page to its streamable tracks when the native player * 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 * 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 * can't be stored in the dump's preview. The browser plays them from bcbits
* directly. * directly.

View File

@@ -1,7 +1,7 @@
/** /**
* Shows the thumbnails a browser can't load from their own site: those on plain * 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. * http, which an https page would block, and those behind hotlink protection.
* It fetches and caches the images `GET /api/proxy-image` serves, which the * 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 * frontend routes a thumbnail through when it is on http or when loading it
* directly failed. * directly failed.
* *
@@ -11,7 +11,7 @@
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts"; import { fetchWithTimeout, readBytesCapped } from "./fetch.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`. * it back under `contentType`.
*/ */
export interface ProxiedImage { export interface ProxiedImage {

View File

@@ -15,7 +15,7 @@ import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts";
* Claims bandcamp.com and its subdomains, where artists have their pages, and * 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 * 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 * fetches the page's streams itself at play time, through
* `GET /api/bandcamp/tracks`. * `GET /api/remote/bandcamp`.
*/ */
export const bandcampProvider: RichContentProvider = { export const bandcampProvider: RichContentProvider = {
name: "bandcamp", name: "bandcamp",

View File

@@ -1,6 +1,6 @@
/** /**
* Previews links to this site's own dump, user and playlist pages from the * Previews links to this site's own dump, user and playlist pages, as
* database, for `rich-content-service.ts`. No request goes out, so this works * `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. * even when the site runs on a private host that `fetchWithTimeout` refuses.
* *
* @module * @module
@@ -8,16 +8,13 @@
import type { RichContent } from "../../../shared/dump.ts"; import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts"; import type { RichContentProvider } from "./provider.ts";
import { getDump } from "../../services/dump-service.ts"; import { getDumpByPath } from "../../services/dump-service.ts";
import { getUserByUsername } from "../../services/user-service.ts"; import { resolveUsername } from "../../services/user-service.ts";
import { getPlaylistById } from "../../services/playlist-service.ts"; import { getPlaylistByPath } from "../../services/playlist-service.ts";
import { APIException } from "../../lib/api-exception.ts"; import { APIException } from "../../lib/api-exception.ts";
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts"; import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
import { isSelfUrl } from "../../lib/self-url.ts"; import { isSelfUrl } from "../../lib/self-url.ts";
import { parsePagePath } from "../../../shared/paths.ts";
const DUMP_RE = /^\/dumps\/([^/]+)$/;
const USER_RE = /^\/users\/([^/]+)$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
/** /**
* Gives a link to this site without a thumbnail a placeholder in the site's * Gives a link to this site without a thumbnail a placeholder in the site's
@@ -30,10 +27,10 @@ const SELF_BRANDING = {
} as const; } as const;
/** /**
* Comes before the generic provider, and claims what `isSelfUrl` accepts. Its * Comes before the generic provider, and claims what `isSelfUrl` accepts. A
* `fetch` rejects with a 404 `APIException` for a missing page, a private dump * profile's tab previews as the profile. Its `fetch` rejects with a 404
* or a private playlist: the preview is stored in the linking dump, which * `APIException` for a missing page, a private dump or a private playlist:
* others may see. * the preview is stored in the linking dump, which others may see.
*/ */
export const selfProvider: RichContentProvider = { export const selfProvider: RichContentProvider = {
name: "self", name: "self",
@@ -47,14 +44,15 @@ export const selfProvider: RichContentProvider = {
}, },
fetch(url: string): Promise<RichContent> { 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 (page.kind === "dump") {
if (dumpMatch) { const dump = getDumpByPath(user.id, page.slug);
const dump = getDump(dumpMatch[1]);
let thumbnailUrl: string | undefined; let thumbnailUrl: string | undefined;
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { 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) { } else if (dump.richContent?.thumbnailUrl) {
thumbnailUrl = dump.richContent.thumbnailUrl; thumbnailUrl = dump.richContent.thumbnailUrl;
} }
@@ -68,30 +66,13 @@ export const selfProvider: RichContentProvider = {
}); });
} }
const userMatch = pathname.match(USER_RE); if (page.kind === "playlist") {
if (userMatch) { const playlist = getPlaylistByPath(user.id, page.slug);
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 (!playlist.isPublic) { if (!playlist.isPublic) {
throw new APIException(404, "Playlist not found"); throw new APIException(404, "Playlist not found");
} }
const thumbnailUrl = playlist.imageMime const thumbnailUrl = playlist.imageMime
? `${PUBLIC_URL}/api/playlists/${playlist.id}/image` ? `${PUBLIC_URL}/api/playlists/${playlist.id}/cover`
: undefined; : undefined;
return Promise.resolve({ return Promise.resolve({
type: "generic", type: "generic",
@@ -103,7 +84,16 @@ export const selfProvider: RichContentProvider = {
}); });
} }
// Unreachable: isSelfUrl accepts only the three paths above. const thumbnailUrl = user.avatarMime
return Promise.resolve({ type: "generic", url }); ? `${PUBLIC_URL}/api/users/${user.id}/avatar`
: undefined;
return Promise.resolve({
type: "generic",
url,
...SELF_BRANDING,
title: user.username,
description: user.description,
thumbnailUrl,
});
}, },
}; };

View File

@@ -1,7 +1,7 @@
/** /**
* Gives the global player a tracklist for a YouTube playlist, which the embed * 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 * plays but doesn't list. It resolves the playlist to its entries' titles and
* lengths, for `GET /api/youtube/playlist`. * lengths, for `GET /api/remote/youtube`.
* *
* They are read from the `ytInitialData` JSON of the public playlist page, * They are read from the `ytInitialData` JSON of the public playlist page,
* whose undocumented shape changes, so both the current `lockupViewModel` rows * whose undocumented shape changes, so both the current `lockupViewModel` rows

View File

@@ -3,10 +3,10 @@
* and the client's `Avatar` component loads it, with the user's `updatedAt` as * and the client's `Avatar` component loads it, with the user's `updatedAt` as
* a cache-busting version. * 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 * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
* caller's user. * caller's user.
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without * - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
* one. * one.
* *
* @module * @module
@@ -14,7 +14,11 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { getUserById, updateUserAvatar } from "../services/user-service.ts"; import {
getUserById,
resolveUserKey,
updateUserAvatar,
} from "../services/user-service.ts";
import { import {
broadcastUserUpdated, broadcastUserUpdated,
updateClientAvatar, updateClientAvatar,
@@ -36,7 +40,7 @@ import { AVATARS_DIR } from "../config.ts";
*/ */
const router: Router = new Router(); 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 authPayload = ctx.state.user;
const contentType = ctx.request.headers.get("content-type") ?? ""; const contentType = ctx.request.headers.get("content-type") ?? "";
if (!contentType.includes("multipart/form-data")) { if (!contentType.includes("multipart/form-data")) {
@@ -70,12 +74,10 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/api/avatars/:userId", async (ctx) => { router.get("/api/users/:key/avatar", async (ctx) => {
const { userId } = ctx.params;
let user; let user;
try { try {
user = getUserById(userId); user = resolveUserKey(ctx.params.key);
} catch { } catch {
ctx.response.status = 404; ctx.response.status = 404;
return; return;
@@ -86,7 +88,7 @@ router.get("/api/avatars/:userId", async (ctx) => {
return; return;
} }
await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime); await serveUploadedFile(ctx, `${AVATARS_DIR}/${user.id}`, user.avatarMime);
}); });
export default router; export default router;

View File

@@ -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;

View File

@@ -1,11 +1,8 @@
/** /**
* Serves the categories dumps are filed under and their feeds, and changes them * Serves the categories dumps are filed under, and changes them for those with
* for those with `category:manage`. * `category:manage`. A category's feed is `GET /api/dumps?category=:slug`.
* *
* - `GET /api/categories` — public. Answers every category, in order. * - `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. * - `POST /api/categories` — permission `category:manage`. Creates a category.
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a * - `PUT /api/categories/:id` — permission `category:manage`. Updates a
* category. * category.
@@ -17,24 +14,19 @@
import { Router } from "@oak/oak"; 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 { APIException } from "../lib/api-exception.ts";
import type { Category } from "../../shared/category.ts"; import type { Category } from "../../shared/category.ts";
import type { Dump } from "../../shared/dump.ts";
import { import {
isCreateCategoryRequest, isCreateCategoryRequest,
isUpdateCategoryRequest, isUpdateCategoryRequest,
} from "../validators/category.ts"; } from "../validators/category.ts";
import { authMiddleware, requirePermission } from "../middleware/auth.ts"; import { authMiddleware, requirePermission } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { import {
createCategory, createCategory,
deleteCategory, deleteCategory,
getCategoryBySlug,
listCategories, listCategories,
listDumpsByCategory,
updateCategory, updateCategory,
} from "../services/category-service.ts"; } from "../services/category-service.ts";
@@ -52,31 +44,6 @@ router.get("/", (ctx) => {
ctx.response.body = responseBody; 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( router.post(
"/", "/",
authMiddleware, authMiddleware,

View File

@@ -1,11 +1,18 @@
/** /**
* Posts, reads, edits and deletes dumps, for the index feed, the dump page, its * Posts, reads, edits and deletes dumps, for the feeds, the dump page, its
* edit page and the create form. `:dumpId` takes an id or a slug. A private * edit page and the create form. `:dumpId` is a dump's id; the dump page reads
* dump answers 404 to anyone but its poster, moderators included. * 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 * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). * (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?url=` — optional sign-in. Answers the dumps already
* pointing at the same thing as `url`, for the create form's duplicate hint. * 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` — optional sign-in. Answers a dump.
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a * - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
@@ -14,21 +21,12 @@
* linked to or from it. * linked to or from it.
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
* playlists it appears in. * 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 * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Updates its title, comment, visibility, categories, and a * `dump:moderate`. Updates its title, slug, comment, visibility, categories,
* link's URL. * and a link's URL. A new slug keeps the old one as an alias, 3 times a day
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * at most but for those with `dump:moderate`.
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 * - `POST /api/dumps/:dumpId/metadata` — signed in, as its poster or with
* MB). * `dump:moderate`. Fetches a link's preview again.
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Removes its custom thumbnail.
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
* with `dump:moderate`. Fetches a link's preview again.
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with * - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Deletes it. * `dump:moderate`. Deletes it.
* *
@@ -43,6 +41,7 @@ import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
import { import {
isCreateUrlDumpRequest, isCreateUrlDumpRequest,
isUpdateDumpRequest, isUpdateDumpRequest,
parseCategoryIds,
} from "../validators/dump.ts"; } from "../validators/dump.ts";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts"; import type { PublicUser } from "../../shared/user.ts";
@@ -52,8 +51,6 @@ import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts"; import { can } from "../lib/permissions.ts";
import { parseOptionalAuth } from "../lib/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts"; import { parsePagination } from "../lib/pagination.ts";
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import { import {
createFileDump, createFileDump,
createUrlDump, createUrlDump,
@@ -62,11 +59,9 @@ import {
getDump, getDump,
listDumps, listDumps,
refreshDumpMetadata, refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump,
setDumpThumbnail,
updateDump, updateDump,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { listDumpsByCategory } from "../services/category-service.ts";
import { getDumpVoters } from "../services/vote-service.ts"; import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts";
@@ -77,24 +72,8 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
*/ */
const router: Router = new Router({ prefix: "/api/dumps" }); const router: Router = new Router({ prefix: "/api/dumps" });
// Each handler resolves `:dumpId` with getDump, which checks privacy and takes // Each handler resolves `:dumpId` with getDump, which checks privacy, before
// a slug, and passes `dump.id` on: the services' writes match on the id alone. // acting on the dump.
/**
* 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 [];
}
}
router.post( router.post(
"/", "/",
@@ -140,17 +119,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) => { router.get("/:dumpId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
@@ -194,8 +162,21 @@ router.get("/:dumpId/playlists", async (ctx) => {
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams); const params = ctx.request.url.searchParams;
const { items, hasMore } = listDumps(page, limit, requestingUserId); 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 { items, hasMore } = category
? listDumpsByCategory(category, page, limit, requestingUserId)
: listDumps(page, limit, requestingUserId);
const responseBody: APIResponse<PaginatedData<Dump>> = { const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true, success: true,
data: { items, hasMore }, data: { items, hasMore },
@@ -203,40 +184,6 @@ router.get("/", async (ctx) => {
ctx.response.body = responseBody; 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) => { router.put("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId; const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
@@ -252,54 +199,16 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
throw new APIException(403, "Not authorized to update dump"); 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 }; const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.put("/:dumpId/thumbnail", 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);
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) => {
const dumpId = ctx.params.dumpId; const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId); const dump = getDump(dumpId, userId);

View File

@@ -1,26 +1,39 @@
/** /**
* Serves file dumps' uploads to the players, viewers and download links of the * Serves and replaces file dumps' uploads, a sub-resource of their dump. The
* frontend, which builds the URL with `dumpFileUrl`. * 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 * - `GET /api/dumps/:dumpId/file?v=&token=` — public; a private dump's file
* its poster's media `token`. Serves the file, inline when that is safe and * needs its poster's media `token`. Serves the file, inline when that is
* as a download otherwise, with byte ranges. * 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 * @module
*/ */
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts"; 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 { DUMPS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts"; import { authenticateMedia } from "../lib/auth.ts";
import { can } from "../lib/permissions.ts";
import { serveFile } from "../lib/serve-file.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 {
dumpFileVersion,
getDump,
replaceFileDump,
} from "../services/dump-service.ts";
/** /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * 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` * Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
@@ -54,7 +67,7 @@ function isInlineSafe(mime: string): boolean {
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase()); 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; const { dumpId } = ctx.params;
// The id names a file on disk: nothing but an id's characters gets through. // The id names a file on disk: nothing but an id's characters gets through.
@@ -110,4 +123,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; export default router;

View File

@@ -1,27 +1,28 @@
/** /**
* Serves the endpoints for what the caller follows, and the index's "followed" * Serves what the caller follows, and the index's "follow" tab built from it.
* tab built from it. What another user follows is served by `routes/users.ts`. * What another user follows is served by `routes/users.ts`.
* *
* - `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. * playlists they follow.
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a * - `PUT /api/me/follow/users/:userId` — signed in. Follows a user.
* page of the public dumps of the users they follow, newest first, optionally * - `DELETE /api/me/follow/users/:userId` — signed in. Stops following a user.
* in one category. * - `PUT /api/me/follow/playlists/:playlistId` — signed in. Follows a public
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in. * playlist.
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
* a playlist.
* - `GET /api/me/feed?from=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/me/feed?from=playlists&page=&limit=&category=` — signed in.
* Answers a page of the public dumps in the public playlists they follow, * Answers a page of the public dumps in the public playlists they follow,
* most recently added first, optionally in one category. * most recently added first, optionally in one category.
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
* playlist.
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
* playlist.
* *
* @module * @module
*/ */
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse, PaginatedData } from "../../shared/api.ts"; import type { APIResponse, PaginatedData } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts"; import type { Dump } from "../../shared/dump.ts";
import type { FollowStatus } from "../../shared/follow.ts"; import type { FollowStatus } from "../../shared/follow.ts";
@@ -56,65 +57,53 @@ const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * 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 status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status }; const body: APIResponse<FollowStatus> = { success: true, data: status };
ctx.response.body = body; ctx.response.body = body;
}); });
router.get("/feed/users", authMiddleware, (ctx) => { router.put("/follow/users/:userId", 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) => {
followUser(ctx.state.user.userId, ctx.params.userId); followUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204; 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); unfollowUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204; 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); followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204; 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); unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204; ctx.response.status = 204;
}); });
router.get("/feed", authMiddleware, (ctx) => {
const params = ctx.request.url.searchParams;
const from = params.get("from");
if (from !== "users" && from !== "playlists") {
throw new APIException(400, "from must be users or playlists");
}
const { page, limit } = parsePagination(params);
const categoryId = resolveCategoryFilter(params);
const userId = ctx.state.user.userId;
const { items, hasMore } = categoryId === "missing"
? EMPTY_FEED
: from === "users"
? getFollowedUsersDumpFeed(userId, page, limit, categoryId)
: getFollowedPlaylistsDumpFeed(userId, page, limit, categoryId);
const body: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = body;
});
export default router; export default router;

174
api/routes/me.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* 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, email and
* username, and answers their user. A new username keeps the old one as an
* alias, at most once in 30 days but for those with `user:manage`.
* - `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.
* - `GET /api/me/playlists?contains=:dumpId` — signed in. Answers each of the
* caller's playlists and whether it holds the dump.
*
* @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 { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {
getUserById,
renameUser,
updateUser,
} from "../services/user-service.ts";
import { getSettings, updateSettings } from "../services/settings-service.ts";
import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts";
import {
broadcastUserUpdated,
updateClientUsername,
} from "../services/ws-service.ts";
import { VALIDATION } from "../../shared/validation.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;
// Only these fields: a stolen token alone must not change the role or the
// password, which needs the current one. The rename first: it is the change
// most likely refused, and a refusal should leave the rest as it was.
if (body.username !== undefined) {
const renamed = renameUser(
userId,
body.username,
can(ctx.state.user, "user:manage"),
);
updateClientUsername(userId, renamed.username);
}
const updated = await updateUser(userId, {
description: body.description,
email: body.email,
});
broadcastUserUpdated(toPublicUser(updated));
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);
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,
});
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;
});
export default router;

View File

@@ -3,9 +3,9 @@
* page, which marks them all read when it opens. New ones arrive over the * page, which marks them all read when it opens. New ones arrive over the
* WebSocket instead, as `notification_created`. * WebSocket instead, as `notification_created`.
* *
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, * - `GET /api/me/notifications?page=&limit=` — signed in. Answers a page of
* newest first. * them, newest first.
* - `POST /api/notifications/read-all` — signed in. Marks them all read. * - `POST /api/me/notifications/read` — signed in. Marks them all read.
* *
* @module * @module
*/ */
@@ -24,7 +24,7 @@ import {
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * 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) => { router.get("/", authMiddleware, (ctx: AuthContext) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
@@ -40,7 +40,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.post("/read-all", authMiddleware, (ctx: AuthContext) => { router.post("/read", authMiddleware, (ctx: AuthContext) => {
markAllRead(ctx.state.user.userId); markAllRead(ctx.state.user.userId);
ctx.response.status = 204; ctx.response.status = 204;
}); });

View File

@@ -0,0 +1,56 @@
/**
* 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`.
*
* @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";
/**
* 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");
}
await resetPassword(ctx.params.token, newPassword);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
export default router;

View File

@@ -1,29 +1,32 @@
/** /**
* Serves playlists, their dumps and their covers. `:playlistId` takes an id or * Serves playlists, their dumps and their covers. `:playlistId` is a
* a slug. * 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. * - `POST /api/playlists` — signed in. Creates a playlist.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist * - `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 * with the dumps the caller may see in it. A private one 404s but for its
* owner. * owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with * - `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 and visibility.
* 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 * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Deletes it. * `playlist:moderate`. Deletes it.
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Adds a dump at the top. * or with `playlist:moderate`. Adds a dump at the top.
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Removes a dump. * 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). * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/image?token=` — public, but a private * - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s * playlist's cover needs its owner's media `token`. Serves its cover, or 404s
* without one. * without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it. * `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 * @module
*/ */
@@ -36,11 +39,7 @@ import {
isReorderPlaylistRequest, isReorderPlaylistRequest,
isUpdatePlaylistRequest, isUpdatePlaylistRequest,
} from "../validators/playlist.ts"; } from "../validators/playlist.ts";
import type { import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../../shared/playlist.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts"; import { can } from "../lib/permissions.ts";
import { import {
@@ -50,7 +49,6 @@ import {
getEditablePlaylist, getEditablePlaylist,
getPlaylist, getPlaylist,
getPlaylistImageInfo, getPlaylistImageInfo,
getPlaylistMembershipsForDump,
removeDumpFromPlaylist, removeDumpFromPlaylist,
reorderPlaylist, reorderPlaylist,
setPlaylistImage, setPlaylistImage,
@@ -71,17 +69,6 @@ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists", 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[]> = {
success: true,
data: getPlaylistMembershipsForDump(dumpId, userId),
};
ctx.response.body = responseBody;
});
router.post("/", authMiddleware, async (ctx) => { router.post("/", authMiddleware, async (ctx) => {
const body: unknown = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isCreatePlaylistRequest(body)) { if (!isCreatePlaylistRequest(body)) {
@@ -148,7 +135,7 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
ctx.response.status = 204; 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") ?? ""; const contentType = ctx.request.headers.get("content-type") ?? "";
if (!contentType.includes("multipart/form-data")) { if (!contentType.includes("multipart/form-data")) {
throw new APIException(400, "Expected multipart/form-data"); throw new APIException(400, "Expected multipart/form-data");
@@ -182,7 +169,7 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; 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 // 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. // is known by the media token in the query string, as for dump files.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");

View File

@@ -1,11 +1,19 @@
/** /**
* Fetches link previews for the create form, and proxies the remote images * Reads other sites for the client, as `api/remote/` does for the server: the
* that the frontend's thumbnails can't load directly. * previews of links on the create form, the images its thumbnails can't load
* directly, and the tracks the players list. On any failure of the last two,
* the player falls back to the embed.
* *
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and * - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
* whether its page could be reached. * and whether its page could be reached.
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through * - `GET /api/remote/image?url=` — public. Serves a remote raster image
* this site; 400 when it isn't one, 502 when its host can't be reached. * through this site; 400 when it isn't one, 502 when its host can't be
* reached.
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
* page's tracks and their stream URLs, for the native player; `force=1`
* skips the server's cache.
* - `GET /api/remote/youtube?list=` — public. Answers a YouTube playlist's
* tracklist, which the global player lists beside the embed.
* *
* @module * @module
*/ */
@@ -13,6 +21,8 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { isValidHttpUrl } from "../remote/fetch.ts"; import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts"; import { fetchProxiedImage } from "../remote/image-proxy.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
import { tryFetchRichContent } from "../services/rich-content-service.ts"; import { tryFetchRichContent } from "../services/rich-content-service.ts";
import { import {
APIErrorCode, APIErrorCode,
@@ -20,16 +30,17 @@ import {
type APIResponse, type APIResponse,
} from "../../shared/api.ts"; } from "../../shared/api.ts";
import type { LinkPreview } from "../../shared/dump.ts"; import type { LinkPreview } from "../../shared/dump.ts";
import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
/** /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * An `APIException` thrown by a handler becomes the JSON error response there.
*/ */
const previewRouter: Router = new Router(); const router: Router = new Router({ prefix: "/api/remote" });
// Signed-in only: an open preview would let anyone make the server fetch pages. // Signed-in only: an open preview would let anyone make the server fetch pages.
previewRouter.get("/api/preview", authMiddleware, async (ctx) => { router.get("/preview", authMiddleware, async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
const responseBody: APIFailure = { const responseBody: APIFailure = {
@@ -69,7 +80,7 @@ function sendImage(
// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS // The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
// page won't load, or when its host's hotlink protection refused the browser. // page won't load, or when its host's hotlink protection refused the browser.
previewRouter.get("/api/proxy-image", async (ctx) => { router.get("/image", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
ctx.response.status = 400; ctx.response.status = 400;
@@ -88,4 +99,33 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
} }
}); });
export default previewRouter; // Public: it reads a public page and answers the URLs that page gives any
// visitor.
router.get("/bandcamp", 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;
});
// Public, like /bandcamp: it only reads a public page.
router.get("/youtube", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? "";
const responseBody: APIResponse<YouTubePlaylist> = {
success: true,
data: await resolveYouTubePlaylist(listId),
};
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
ctx.response.body = responseBody;
});
export default router;

74
api/routes/sessions.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Signs users in, from the client's `/login` page. A session is a token the
* client keeps; signing out forgets it, with no request.
*
* - `POST /api/sessions` — public. Answers the user and their tokens;
* rate-limited per account.
*
* @module
*/
import { Router } from "@oak/oak";
import { type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { AuthResponse } from "../../shared/user.ts";
import { isLoginUserRequest } from "../validators/user.ts";
import { toSelfUser } from "../model/user.ts";
import { issueTokens } from "../lib/jwt.ts";
import { hashPassword, verifyPassword } from "../lib/password.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import { getUserByUsername } from "../services/user-service.ts";
// Hashed once, at startup, for the sign-ins of usernames that don't exist.
const UNKNOWN_USER_HASH: Promise<string> = hashPassword(crypto.randomUUID());
/** Allows a burst of 10 login attempts per account, then one every 30s. */
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
/**
* 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/sessions" });
router.post("/", async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isLoginUserRequest(body)) {
throw new APIException(400, "Invalid request");
}
// Per account, so guessing one user's password is slow however many
// connections it comes from.
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
throw new APIException(
429,
"Too many login attempts — try again in a minute",
);
}
let user;
try {
user = getUserByUsername(body.username);
} catch {
user = undefined;
}
// An unknown username gets the same answer as a wrong password, after the
// same work, so neither the message nor the timing tells which exist.
const valid = await verifyPassword(
body.password,
user?.passwordHash ?? await UNKNOWN_USER_HASH,
);
if (!user || !valid) {
throw new APIException(401, "Invalid username or password");
}
const responseBody: APIResponse<AuthResponse> = {
success: true,
data: { ...await issueTokens(user), user: toSelfUser(user) },
};
ctx.response.body = responseBody;
});
export default router;

View File

@@ -1,34 +1,51 @@
/** /**
* Serves dumps' thumbnails to the cards and pages that show a file dump, which * Serves and changes dumps' thumbnails, a sub-resource of their dump. The
* build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are * cards and pages that show a file dump build the URL with
* generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`. * `dumpThumbnailUrl`, and the edit page uploads a custom one. Thumbnails made
* from an upload are generated with ffmpeg on first request and kept in
* `THUMBNAILS_DIR`.
* *
* - `GET /api/thumbnails/:dumpId?v=&token=` — public; a private dump's * - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom * thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a * thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404 * scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither. * when there is neither.
* - `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.
* *
* @module * @module
*/ */
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import { import {
dumpFileVersion, dumpFileVersion,
getDump, getDump,
getDumpThumbnailInfo, getDumpThumbnailInfo,
removeDumpThumbnail,
setDumpThumbnail,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { serveFile } from "../lib/serve-file.ts"; import { serveFile } from "../lib/serve-file.ts";
import { serveUploadedFile } from "../lib/upload.ts"; import {
replaceUploadedFile,
serveUploadedFile,
validateImageUpload,
} from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts"; import { authenticateMedia } from "../lib/auth.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
/** /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * An `APIException` thrown by a handler becomes the JSON error response there.
*/ */
const router: Router = new Router({ prefix: "/api/thumbnails" }); const router: Router = new Router({ prefix: "/api/dumps" });
/** /**
* Spares a process per uncached thumbnail request: whether ffmpeg runs is * Spares a process per uncached thumbnail request: whether ffmpeg runs is
@@ -169,7 +186,7 @@ async function ensureGenerated(
return await pending.then(() => true, () => false); return await pending.then(() => true, () => false);
} }
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId/thumbnail", async (ctx) => {
const { dumpId } = ctx.params; const { dumpId } = ctx.params;
// The id names files on disk: nothing but an id's characters gets through. // The id names files on disk: nothing but an id's characters gets through.
@@ -253,4 +270,59 @@ router.get("/:dumpId", async (ctx) => {
throw new APIException(404, "No thumbnail for this dump"); throw new APIException(404, "No thumbnail for this dump");
}); });
/**
* Returns the dump `ctx` names if the caller may change it, as its poster or
* with `dump:moderate`.
*
* @throws {APIException} 404 when the caller can't see it, 403 when they may
* not change it.
*/
function editableDump(ctx: AuthContext<"/:dumpId/thumbnail">): Dump {
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(403, "Not authorized to update dump");
}
return dump;
}
router.put(
"/:dumpId/thumbnail",
authMiddleware,
async (ctx: AuthContext<"/:dumpId/thumbnail">) => {
const dump = editableDump(ctx);
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 responseBody: APIResponse<Dump> = {
success: true,
data: setDumpThumbnail(dump.id, mime),
};
ctx.response.body = responseBody;
},
);
router.delete(
"/:dumpId/thumbnail",
authMiddleware,
async (ctx: AuthContext<"/:dumpId/thumbnail">) => {
const dump = editableDump(ctx);
const responseBody: APIResponse<Dump> = {
success: true,
data: await removeDumpThumbnail(dump.id),
};
ctx.response.body = responseBody;
},
);
export default router; export default router;

49
api/routes/unsubscribe.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Turns a digest off from the link in its email, without signing in. Mail
* clients post here for a one-click unsubscribe (RFC 8058), and so does the
* client's `/unsubscribe` page.
*
* - `POST /api/unsubscribe?token=` — public, with a digest's unsubscribe
* token. Turns that digest off and answers its cadence.
* - `POST /api/users/unsubscribe?token=` — the same, at its v3 path, which the
* `List-Unsubscribe` header of every digest sent before v4 names. Those
* tokens never expire, so this path stays for good.
*
* @module
*/
import { Router, type RouterMiddleware } from "@oak/oak";
import { type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { DIGEST_SETTING, type DigestCadence } from "../../shared/settings.ts";
import { verifyUnsubscribeToken } from "../lib/jwt.ts";
import { getUserById } from "../services/user-service.ts";
import { updateSettings } from "../services/settings-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();
// The body is ignored: a mail client's one-click post sends a form saying
// "List-Unsubscribe=One-Click", the client's page sends nothing.
const unsubscribe: RouterMiddleware<string> = async (ctx) => {
const token = ctx.request.url.searchParams.get("token") ?? "";
const payload = await verifyUnsubscribeToken(token);
if (!payload) throw new APIException(400, "Invalid unsubscribe link");
// Throws a 404 once the account is gone.
getUserById(payload.userId);
updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false });
const responseBody: APIResponse<{ cadence: DigestCadence }> = {
success: true,
data: { cadence: payload.cadence },
};
ctx.response.body = responseBody;
};
router.post("/api/unsubscribe", unsubscribe);
router.post("/api/users/unsubscribe", unsubscribe);
export default router;

View File

@@ -1,49 +1,37 @@
/** /**
* Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles, * Serves sign-up and user profiles, with what each user posted, upvoted, made,
* with what each user posted, upvoted, follows and invited. A profile sent to * follows and invited, and the dumps and playlists under them. A user's `:key`
* anyone but its user goes through `toPublicUser`, which drops the email. * is their id or `@name`, a current or former name in any case
* (`resolveUserKey`). A profile sent to anyone goes through `toPublicUser`,
* which drops the email. The caller's own account is `routes/me.ts`'s.
* *
* - `POST /api/users/register` — public, with a valid invite. Creates an * - `POST /api/users` — public, with a valid invite. Creates an account, sends
* account, sends the welcome email, and answers the user and their tokens. * the welcome email, and answers the user and their tokens.
* - `POST /api/users/login` — public. Answers the user and their tokens; * - `GET /api/users?q=` — public. Answers up to 8 users whose name starts
* rate-limited per account. * with `q`, for @mention suggestions.
* - `GET /api/users/me` — signed in. Answers the caller's own user. * - `GET /api/users/:key` — public. Answers a user's profile.
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a * - `GET /api/users/:key/dumps?page=&limit=` — optional sign-in. Answers a
* session that predates them.
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
* the account with that address, if any; always answers 200.
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
* password.
* - `POST /api/users/me/change-password` — signed in, with the current
* password. Sets a new one, ending every other session, and answers fresh
* tokens.
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
* description and email.
* - `GET /api/users/me/settings` — signed in. Answers the caller's settings.
* - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's
* settings, and answers them all.
* - `POST /api/users/unsubscribe?token=` — public, with a digest's unsubscribe
* token. Turns that digest off and answers its cadence. Mail clients post
* here for a one-click unsubscribe (RFC 8058), and so does the client's
* `/unsubscribe` page.
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
* user's role.
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
* starts with `q`, for @mention suggestions.
* - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id.
* - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a
* page of the users they follow.
* - `GET /api/users/:username/followed-playlists?page=&limit=` — public.
* Answers a page of the public playlists they follow.
* - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in.
* Answers a page of their playlists, the private ones only to themselves.
* - `GET /api/users/:username/invitees` — public. Answers the tree of users
* they invited.
* - `GET /api/users/:username` — public. Answers a user's profile.
* - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a
* page of their dumps, the private ones only to themselves. * page of their dumps, the private ones only to themselves.
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a * - `GET /api/users/:key/dumps/:slug` — optional sign-in. Answers their dump
* whose slug is or was `slug`, with its current slug; a private one 404s
* but for its poster.
* - `GET /api/users/:key/upvoted?page=&limit=` — optional sign-in. Answers a
* page of the dumps they upvoted, private ones only to themselves. * page of the dumps they upvoted, private ones only to themselves.
* - `GET /api/users/:key/playlists?page=&limit=` — optional sign-in. Answers a
* page of the playlists they made, the private ones only to themselves.
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
* playlist whose slug is or was `slug`, with its current slug and the dumps
* the caller may see; a private one 404s but for its owner.
* - `GET /api/users/:key/follow/users?page=&limit=` — public. Answers a page of
* the users they follow.
* - `GET /api/users/:key/follow/playlists?page=&limit=` — public. Answers a
* page of the public playlists they follow.
* - `GET /api/users/:key/invitees` — public. Answers the tree of users they
* invited.
* - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another
* user's role.
* - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a
* user, with no rate limit.
* *
* @module * @module
*/ */
@@ -54,32 +42,20 @@ import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import { import {
type AuthResponse, type AuthResponse,
type AuthTokens,
type Invitee, type Invitee,
isRole, isRole,
type MediaTokenResponse,
type PublicUser, type PublicUser,
type SelfUser,
type UserSuggestion, type UserSuggestion,
} from "../../shared/user.ts"; } from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts"; import type { Dump } from "../../shared/dump.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import { import {
fieldOf, fieldOf,
isLoginUserRequest,
isRegisterUserRequest, isRegisterUserRequest,
isUpdateUserRequest,
validateRegisterUserRequest, validateRegisterUserRequest,
} from "../validators/user.ts"; } from "../validators/user.ts";
import type { Playlist } from "../../shared/playlist.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts";
import { toPublicUser, toSelfUser, type User } from "../model/user.ts"; import { issueTokens } from "../lib/jwt.ts";
import {
createJWT,
createMediaToken,
verifyUnsubscribeToken,
} from "../lib/jwt.ts";
import { hashPassword, verifyPassword } from "../lib/password.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import { import {
type AuthContext, type AuthContext,
authMiddleware, authMiddleware,
@@ -90,64 +66,39 @@ import { parsePagination } from "../lib/pagination.ts";
import { import {
createUser, createUser,
getInviteTree, getInviteTree,
getUserById, renameUser,
getUserByUsername, resolveUserKey,
searchUsers, searchUsers,
sendWelcomeEmail, sendWelcomeEmail,
updateUser, updateUser,
} from "../services/user-service.ts"; } from "../services/user-service.ts";
import { claimInvite, releaseInvite } from "../services/invite-service.ts"; import { claimInvite, releaseInvite } from "../services/invite-service.ts";
import { import {
requestPasswordReset, broadcastUserUpdated,
resetPassword, updateClientUsername,
} from "../services/password-reset-service.ts"; } from "../services/ws-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
import {
DIGEST_SETTING,
type DigestCadence,
type UserSettings,
} from "../../shared/settings.ts";
import { isUpdateSettingsRequest } from "../validators/settings.ts";
import { getSettings, updateSettings } from "../services/settings-service.ts";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import { import {
getDumpByPath,
getDumpsByUser, getDumpsByUser,
getVotedDumpsByUser, getVotedDumpsByUser,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { listPlaylistsByUser } from "../services/playlist-service.ts"; import {
getPlaylist,
getPlaylistByPath,
listPlaylistsByUser,
} from "../services/playlist-service.ts";
import { import {
getFollowedPlaylistsByUser, getFollowedPlaylistsByUser,
getFollowedUsersByUser, getFollowedUsersByUser,
} from "../services/follow-service.ts"; } from "../services/follow-service.ts";
// Hashed once, at startup, for the sign-ins of usernames that don't exist.
const UNKNOWN_USER_HASH: Promise<string> = hashPassword(crypto.randomUUID());
/** /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * 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. * An `APIException` thrown by a handler becomes the JSON error response there.
*/ */
const router: Router = new Router({ prefix: "/api/users" }); const router: Router = new Router({ prefix: "/api/users" });
/** Allows a burst of 10 login attempts per account, then one every 30s. */ router.post("/", async (ctx) => {
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
/**
* Returns a new session token and media token for `user`, as sign-up, sign-in
* and a password change answer them.
*/
async function issueTokens(user: User): Promise<AuthTokens> {
return {
token: await createJWT({
userId: user.id,
username: user.username,
role: user.role,
}),
mediaToken: await createMediaToken(user.id),
};
}
router.post("/register", async (ctx) => {
const body: unknown = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isRegisterUserRequest(body)) { if (!isRegisterUserRequest(body)) {
@@ -179,208 +130,7 @@ router.post("/register", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.post("/login", async (ctx) => { router.get("/", (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isLoginUserRequest(body)) {
throw new APIException(400, "Invalid request");
}
// Per account, so guessing one user's password is slow however many
// connections it comes from.
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
throw new APIException(
429,
"Too many login attempts — try again in a minute",
);
}
let user;
try {
user = getUserByUsername(body.username);
} catch {
user = undefined;
}
// An unknown username gets the same answer as a wrong password, after the
// same work, so neither the message nor the timing tells which exist.
const valid = await verifyPassword(
body.password,
user?.passwordHash ?? await UNKNOWN_USER_HASH,
);
if (!user || !valid) {
throw new APIException(401, "Invalid username or password");
}
const responseBody: APIResponse<AuthResponse> = {
success: true,
data: { ...await issueTokens(user), user: toSelfUser(user) },
};
ctx.response.body = responseBody;
});
router.get("/me", authMiddleware, (ctx: AuthContext) => {
const responseBody: APIResponse<SelfUser> = {
success: true,
data: toSelfUser(getUserById(ctx.state.user.userId)),
};
ctx.response.body = responseBody;
});
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
const responseBody: APIResponse<MediaTokenResponse> = {
success: true,
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
};
ctx.response.body = responseBody;
});
// Answers the same whatever happens, so it can't tell which addresses have an
// account.
router.post("/request-password-reset", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const email = fieldOf(body, "email");
const trimmed = typeof email === "string" ? email.trim() : "";
if (trimmed) {
await requestPasswordReset(trimmed).catch((err) =>
console.error("[request-password-reset]", err)
);
}
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
router.post("/reset-password", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const token = fieldOf(body, "token");
const newPassword = fieldOf(body, "newPassword");
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(400, "Invalid request");
}
await resetPassword(token, newPassword);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
router.post("/me/change-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);
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,
});
const responseBody: APIResponse<AuthTokens> = {
success: true,
data: await issueTokens(updated),
};
ctx.response.body = responseBody;
});
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body: unknown = await ctx.request.body.json();
if (!isUpdateUserRequest(body)) {
throw new APIException(400, "Invalid request");
}
// Only the profile fields: a stolen token alone must not change the role,
// the password (which needs the current one) or the username (which is
// fixed).
const updated = await updateUser(ctx.state.user.userId, {
description: body.description,
email: body.email,
});
const publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
});
router.get("/me/settings", authMiddleware, (ctx: AuthContext) => {
const responseBody: APIResponse<UserSettings> = {
success: true,
data: getSettings(ctx.state.user.userId),
};
ctx.response.body = responseBody;
});
router.patch("/me/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;
});
// The body is ignored: a mail client's one-click post sends a form saying
// "List-Unsubscribe=One-Click", the client's page sends nothing.
router.post("/unsubscribe", async (ctx) => {
const token = ctx.request.url.searchParams.get("token") ?? "";
const payload = await verifyUnsubscribeToken(token);
if (!payload) throw new APIException(400, "Invalid unsubscribe link");
// Throws a 404 once the account is gone.
getUserById(payload.userId);
updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false });
const responseBody: APIResponse<{ cadence: DigestCadence }> = {
success: true,
data: { cadence: payload.cadence },
};
ctx.response.body = responseBody;
});
router.patch(
"/:userId/role",
authMiddleware,
requirePermission("user:manage"),
async (ctx: AuthContext<"/:userId/role">) => {
const body: unknown = await ctx.request.body.json();
const role = fieldOf(body, "role");
if (!isRole(role)) {
throw new APIException(400, "Invalid role");
}
// So that an admin can't demote themselves by mistake and leave no admin.
if (ctx.params.userId === ctx.state.user.userId) {
throw new APIException(400, "Cannot change your own role");
}
const updated = await updateUser(ctx.params.userId, { role });
const publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
},
);
router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const responseBody: APIResponse<UserSuggestion[]> = { const responseBody: APIResponse<UserSuggestion[]> = {
success: true, success: true,
@@ -389,75 +139,16 @@ router.get("/search", (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/by-id/:userId", (ctx) => { router.get("/:key", (ctx) => {
const responseBody: APIResponse<PublicUser> = { const responseBody: APIResponse<PublicUser> = {
success: true, success: true,
data: toPublicUser(getUserById(ctx.params.userId)), data: toPublicUser(resolveUserKey(ctx.params.key)),
}; };
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:username/followed-users", (ctx) => { router.get("/:key/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = resolveUserKey(ctx.params.key);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedUsersByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true,
data: {
items: items.map(toPublicUser),
hasMore,
},
};
ctx.response.body = responseBody;
});
router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.get("/:username/playlists", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const responseBody: APIResponse<Invitee[]> = {
success: true,
data: getInviteTree(user.id),
};
ctx.response.body = responseBody;
});
router.get("/:username", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
data: toPublicUser(getUserByUsername(ctx.params.username)),
};
ctx.response.body = responseBody;
});
router.get("/:username/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx); const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const includePrivate = requestingUserId === user.id; const includePrivate = requestingUserId === user.id;
@@ -474,8 +165,18 @@ router.get("/:username/dumps", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:username/votes", async (ctx) => { router.get("/:key/dumps/:slug", async (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const responseBody: APIResponse<Dump> = {
success: true,
data: getDumpByPath(user.id, ctx.params.slug, requestingUserId),
};
ctx.response.body = responseBody;
});
router.get("/:key/upvoted", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx); const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getVotedDumpsByUser( const { items, hasMore } = getVotedDumpsByUser(
@@ -491,4 +192,116 @@ router.get("/:username/votes", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:key/playlists", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.get("/:key/playlists/:slug", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const responseBody: APIResponse<PlaylistWithDumps> = {
success: true,
data: getPlaylist(
getPlaylistByPath(user.id, ctx.params.slug),
requestingUserId,
),
};
ctx.response.body = responseBody;
});
router.get("/:key/follow/users", (ctx) => {
const user = resolveUserKey(ctx.params.key);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedUsersByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true,
data: {
items: items.map(toPublicUser),
hasMore,
},
};
ctx.response.body = responseBody;
});
router.get("/:key/follow/playlists", (ctx) => {
const user = resolveUserKey(ctx.params.key);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.get("/:key/invitees", (ctx) => {
const user = resolveUserKey(ctx.params.key);
const responseBody: APIResponse<Invitee[]> = {
success: true,
data: getInviteTree(user.id),
};
ctx.response.body = responseBody;
});
router.patch(
"/:key/role",
authMiddleware,
requirePermission("user:manage"),
async (ctx: AuthContext<"/:key/role">) => {
const body: unknown = await ctx.request.body.json();
const role = fieldOf(body, "role");
if (!isRole(role)) {
throw new APIException(400, "Invalid role");
}
const user = resolveUserKey(ctx.params.key);
// So that an admin can't demote themselves by mistake and leave no admin.
if (user.id === ctx.state.user.userId) {
throw new APIException(400, "Cannot change your own role");
}
const updated = await updateUser(user.id, { role });
const publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
},
);
router.patch(
"/:key/username",
authMiddleware,
requirePermission("user:manage"),
async (ctx: AuthContext<"/:key/username">) => {
const body: unknown = await ctx.request.body.json();
const username = fieldOf(body, "username");
if (typeof username !== "string") {
throw new APIException(400, "Invalid request");
}
const user = resolveUserKey(ctx.params.key);
const publicUser = toPublicUser(renameUser(user.id, username, true));
updateClientUsername(publicUser.id, publicUser.username);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
},
);
export default router; export default router;

View File

@@ -1,35 +0,0 @@
/**
* Serves the titles and lengths of a YouTube playlist's entries, which the
* global player lists beside the embed. On any failure it shows the embed
* without a tracklist.
*
* - `GET /api/youtube/playlist?list=` — public. Answers the playlist's
* tracklist.
*
* @module
*/
import { Router } from "@oak/oak";
import type { APIResponse } from "../../shared/api.ts";
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
import type { YouTubePlaylist } from "../../shared/player.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const youtubeRouter: Router = new Router();
// Public, like /api/bandcamp/tracks: it only reads a public page.
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? "";
const responseBody: APIResponse<YouTubePlaylist> = {
success: true,
data: await resolveYouTubePlaylist(listId),
};
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
ctx.response.body = responseBody;
});
export default youtubeRouter;

View File

@@ -13,7 +13,7 @@ import {
dumpRowToApi, dumpRowToApi,
isDumpRow, isDumpRow,
} from "../model/dump.ts"; } from "../model/dump.ts";
import { UUID_RE } from "../lib/slugify.ts"; import { parsePagePath } from "../../shared/paths.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { parseRows } from "../model/rows.ts"; import { parseRows } from "../model/rows.ts";
@@ -24,25 +24,34 @@ import { parseRows } from "../model/rows.ts";
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
/** /**
* Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what * Returns the id of the dump whose page, or its editor, `pathname` is, by its
* follows it, so an `/edit` or `#comment-x` link counts too. The host isn't * owner's name and its slug, current or former; null when it is no dump's.
* checked, since the site's public URL differs between setups. * The host isn't checked, since the site's public URL differs between setups;
* a query or a fragment (`#comment-x`) isn't in a pathname anyway.
*/ */
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; function resolveDumpPathId(pathname: string): string | null {
const page = parsePagePath(pathname);
/** Returns the id of the dump with id or slug `idOrSlug`, or null. */ if (page?.kind !== "dump") return null;
function resolveDumpPathId(idOrSlug: string): string | null { const row = db.prepare(
const row = UUID_RE.test(idOrSlug) `SELECT d.id FROM dumps d
? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) JOIN users u ON u.id = d.user_id
: db.prepare(`SELECT id FROM dumps WHERE slug = ?;`).get(idOrSlug); LEFT JOIN user_aliases ua ON ua.user_id = u.id
return (row as { id: string } | undefined)?.id ?? null; WHERE (lower(u.username) = lower(:name) OR lower(ua.username) = lower(:name))
AND (d.slug = :slug OR d.id IN (SELECT dump_id FROM dump_slug_aliases
WHERE user_id = u.id AND slug = :slug))
LIMIT 1;`,
).get({ name: page.username, slug: page.slug }) as
| { id: string }
| undefined;
return row?.id ?? null;
} }
/** /**
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
* of its comments (`comment`), with the links its current `text` holds. A link * of its comments (`comment`), with the links its current `text` holds. A link
* counts when it is a dump's page on this site, or when it is the URL a dump * counts when it is a dump's page on this site (`/@owner/dumps/<slug>`), or
* was posted with, compared by `canonicalizeUrl` as reposts are. The dump and * when it is the URL a dump was posted with, compared by `canonicalizeUrl` as
* reposts are. The dump and
* comment services call it on every save, and with an empty `text` when a * comment services call it on every save, and with an empty `text` when a
* comment is deleted. * comment is deleted.
*/ */
@@ -62,11 +71,10 @@ export function relinkBacklinks(
} catch { } catch {
continue; continue;
} }
const pathMatch = DUMP_PATH_RE.exec(parsed.pathname); const resolved = resolveDumpPathId(parsed.pathname);
if (pathMatch) { if (resolved) {
const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); targetIds.add(resolved);
if (resolved) targetIds.add(resolved); } else if (parsePagePath(parsed.pathname)?.kind !== "dump") {
} else {
const canonical = canonicalizeUrl(match[0]); const canonical = canonicalizeUrl(match[0]);
if (canonical) externalUrls.add(canonical); if (canonical) externalUrls.add(canonical);
} }

View File

@@ -21,7 +21,8 @@ import {
isDumpRow, isDumpRow,
} from "../model/dump.ts"; } from "../model/dump.ts";
import { type Page, pageOf } from "../lib/pagination.ts"; import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { isReservedSlug } from "../lib/reserved-slugs.ts";
import { SLUG_RE } from "../../shared/slug.ts";
import { VALIDATION } from "../../shared/validation.ts"; import { VALIDATION } from "../../shared/validation.ts";
import { parseRows } from "../model/rows.ts"; import { parseRows } from "../model/rows.ts";
@@ -30,7 +31,7 @@ import { parseRows } from "../model/rows.ts";
* with a message for each case. * with a message for each case.
*/ */
function validateSlug(slug: string): void { function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) { if (!SLUG_RE.test(slug)) {
throw new APIException( throw new APIException(
400, 400,
"Slug must be lowercase letters, numbers and hyphens", "Slug must be lowercase letters, numbers and hyphens",
@@ -91,7 +92,7 @@ export function getCategoryById(id: string): Category {
} }
/** /**
* Returns the category at `slug`, for `GET /api/categories/:slug`. * Returns the category at `slug`, for {@link listDumpsByCategory}.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.
*/ */
@@ -291,7 +292,7 @@ export function attachCategoryIds(dumps: Dump[]): void {
/** /**
* Returns a page of the dumps in the category at `slug`, newest first, for * Returns a page of the dumps in the category at `slug`, newest first, for
* `GET /api/categories/:slug/dumps`. Private dumps are left out, except * `GET /api/dumps?category=`. Private dumps are left out, except
* `requestingUserId`'s own. * `requestingUserId`'s own.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.

View File

@@ -22,7 +22,12 @@ import {
type DigestCadence, type DigestCadence,
} from "../../shared/settings.ts"; } from "../../shared/settings.ts";
import { SETTINGS_COLUMNS } from "../model/settings.ts"; import { SETTINGS_COLUMNS } from "../model/settings.ts";
import type { NotificationType } from "../../shared/notification.ts"; import type {
DumpRef,
NotificationType,
PlaylistRef,
} from "../../shared/notification.ts";
import { dumpPath, feedPath, playlistPath } from "../../shared/paths.ts";
import { import {
type ActivityKind, type ActivityKind,
type DigestContent, type DigestContent,
@@ -35,6 +40,7 @@ import { createUnsubscribeToken } from "../lib/jwt.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { getSettings } from "./settings-service.ts"; import { getSettings } from "./settings-service.ts";
import { dumpFileVersion } from "./dump-service.ts"; import { dumpFileVersion } from "./dump-service.ts";
import { withCurrentRefs } from "./notification-service.ts";
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
/** How long each cadence's period lasts. */ /** How long each cadence's period lasts. */
@@ -67,11 +73,6 @@ interface Window {
to: string; to: string;
} }
const dumpUrl = (row: { id: string; slug: string | null }) =>
`${FRONTEND_URL}/dumps/${encodeURIComponent(row.slug ?? row.id)}`;
const playlistUrl = (row: { id: string; slug: string | null }) =>
`${FRONTEND_URL}/playlists/${encodeURIComponent(row.slug ?? row.id)}`;
/** Flattens Markdown into a short line of text for a quote. */ /** Flattens Markdown into a short line of text for a quote. */
function quoteOf(markdown: string | undefined | null): string | undefined { function quoteOf(markdown: string | undefined | null): string | undefined {
if (!markdown) return undefined; if (!markdown) return undefined;
@@ -95,7 +96,7 @@ const DUMP_COLS = `d.id, d.slug, d.title, d.kind, d.url, d.user_id,
interface DumpRow { interface DumpRow {
id: string; id: string;
slug: string | null; slug: string;
title: string; title: string;
kind: string; kind: string;
url: string | null; url: string | null;
@@ -126,7 +127,7 @@ function thumbnailOf(row: DumpRow): string | null {
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
}); });
return `${PUBLIC_URL}/api/thumbnails/${row.id}?v=${version}`; return `${PUBLIC_URL}/api/dumps/${row.id}/thumbnail?v=${version}`;
} }
let thumbnailUrl: unknown; let thumbnailUrl: unknown;
try { try {
@@ -139,7 +140,7 @@ function thumbnailOf(row: DumpRow): string | null {
if (typeof thumbnailUrl !== "string") return null; if (typeof thumbnailUrl !== "string") return null;
if (thumbnailUrl.startsWith("https://")) return thumbnailUrl; if (thumbnailUrl.startsWith("https://")) return thumbnailUrl;
if (thumbnailUrl.startsWith("http://")) { if (thumbnailUrl.startsWith("http://")) {
return `${PUBLIC_URL}/api/proxy-image?url=${ return `${PUBLIC_URL}/api/remote/image?url=${
encodeURIComponent(thumbnailUrl) encodeURIComponent(thumbnailUrl)
}`; }`;
} }
@@ -157,7 +158,9 @@ function toDigestDump(row: DumpRow, readerId: string): DigestDump {
} }
return { return {
title: row.title, title: row.title,
url: dumpUrl(row), url: `${FRONTEND_URL}${
dumpPath({ ownerUsername: row.poster, slug: row.slug })
}`,
poster: row.poster, poster: row.poster,
domain, domain,
thumbnail: thumbnailOf(row), thumbnail: thumbnailOf(row),
@@ -205,7 +208,7 @@ function activityOf(
}[]; }[];
const highlights = rows.flatMap((row) => { const highlights = rows.flatMap((row) => {
try { try {
const h = highlightOf(row.type, JSON.parse(row.data)); const h = highlightOf(row.type, withCurrentRefs(JSON.parse(row.data)));
return h ? [{ ...h, at: new Date(row.created_at) }] : []; return h ? [{ ...h, at: new Date(row.created_at) }] : [];
} catch { } catch {
return []; return [];
@@ -219,24 +222,35 @@ function activityOf(
const commentBody = (id: string) => const commentBody = (id: string) =>
(db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`) (db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`)
.get(id) as { body: string } | undefined)?.body; .get(id) as { body: string } | undefined)?.body;
const dumpTarget = (id: string, fallbackTitle: string) => { /**
const row = db.prepare(`SELECT id, slug, title FROM dumps WHERE id = ?;`) * Returns a highlight's dump, titled `title`, linked where the notification's
.get(id) as { id: string; slug: string | null; title: string } | undefined; * joined ref says it is now, or unlinked once it is deleted.
return row ? { title: row.title, url: dumpUrl(row) } : { */
title: fallbackTitle, const dumpTarget = (ref: DumpRef, title: string) => ({
url: `${FRONTEND_URL}/dumps/${encodeURIComponent(id)}`, title,
}; url: ref.dumpSlug && ref.dumpOwnerUsername
}; ? `${FRONTEND_URL}${
const playlistTarget = (id: string, fallbackTitle: string) => { dumpPath({ ownerUsername: ref.dumpOwnerUsername, slug: ref.dumpSlug })
const row = db.prepare(`SELECT id, slug, title FROM playlists WHERE id = ?;`) }`
.get(id) as { id: string; slug: string | null; title: string } | undefined; : null,
return row ? { title: row.title, url: playlistUrl(row) } : { });
title: fallbackTitle, /** Returns a highlight's playlist as {@link dumpTarget} does its dump. */
url: `${FRONTEND_URL}/playlists/${encodeURIComponent(id)}`, const playlistTarget = (ref: PlaylistRef, title: string) => ({
}; title,
}; url: ref.playlistSlug && ref.playlistOwnerUsername
? `${FRONTEND_URL}${
playlistPath({
ownerUsername: ref.playlistOwnerUsername,
slug: ref.playlistSlug,
})
}`
: null,
});
/** Returns the highlight a notification makes, or null for another kind. */ /**
* Returns the highlight a notification makes, from its data with the refs
* joined, or null for another kind.
*/
function highlightOf( function highlightOf(
type: NotificationType, type: NotificationType,
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
@@ -249,20 +263,20 @@ function highlightOf(
return { return {
kind: "mentionComment", kind: "mentionComment",
who: data.mentionerUsername, who: data.mentionerUsername,
target: dumpTarget(data.dumpId, data.contextTitle), target: dumpTarget(data, data.contextTitle),
quote: quoteOf(commentBody(data.contextId)), quote: quoteOf(commentBody(data.contextId)),
}; };
case "dump": case "dump":
return { return {
kind: "mentionDump", kind: "mentionDump",
who: data.mentionerUsername, who: data.mentionerUsername,
target: dumpTarget(data.contextId, data.contextTitle), target: dumpTarget(data, data.contextTitle),
}; };
case "playlist": case "playlist":
return { return {
kind: "mentionPlaylist", kind: "mentionPlaylist",
who: data.mentionerUsername, who: data.mentionerUsername,
target: playlistTarget(data.contextId, data.contextTitle), target: playlistTarget(data, data.contextTitle),
}; };
case "chat": case "chat":
return { return {
@@ -279,14 +293,14 @@ function highlightOf(
return { return {
kind: "comment", kind: "comment",
who: data.commenterUsername, who: data.commenterUsername,
target: dumpTarget(data.dumpId, data.dumpTitle), target: dumpTarget(data, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)), quote: quoteOf(commentBody(data.commentId)),
}; };
case "playlist_followed": case "playlist_followed":
return { return {
kind: "playlistFollow", kind: "playlistFollow",
who: data.followerUsername, who: data.followerUsername,
target: playlistTarget(data.playlistId, data.playlistTitle), target: playlistTarget(data, data.playlistTitle),
}; };
case "user_followed": case "user_followed":
return { kind: "userFollow", who: data.followerUsername }; return { kind: "userFollow", who: data.followerUsername };
@@ -294,7 +308,7 @@ function highlightOf(
return { return {
kind: "commentLike", kind: "commentLike",
who: data.likerUsername, who: data.likerUsername,
target: dumpTarget(data.dumpId, data.dumpTitle), target: dumpTarget(data, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)), quote: quoteOf(commentBody(data.commentId)),
}; };
} }
@@ -393,7 +407,7 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] {
ORDER BY dumps DESC, p.created_at DESC;`, ORDER BY dumps DESC, p.created_at DESC;`,
).all(w.from, w.to) as { ).all(w.from, w.to) as {
id: string; id: string;
slug: string | null; slug: string;
title: string; title: string;
owner: string; owner: string;
dumps: number; dumps: number;
@@ -432,7 +446,9 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] {
membersMore: Math.max(0, members.length - MEMBERS_MAX), membersMore: Math.max(0, members.length - MEMBERS_MAX),
playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({ playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({
title: p.title, title: p.title,
url: playlistUrl(p), url: `${FRONTEND_URL}${
playlistPath({ ownerUsername: p.owner, slug: p.slug })
}`,
owner: p.owner, owner: p.owner,
dumps: p.dumps, dumps: p.dumps,
})), })),
@@ -473,11 +489,9 @@ export function buildDigest(
community, community,
links: { links: {
notifications: `${FRONTEND_URL}/notifications`, notifications: `${FRONTEND_URL}/notifications`,
followed: `${FRONTEND_URL}/~/followed`, followed: `${FRONTEND_URL}${feedPath(undefined, "follow")}`,
chat: `${FRONTEND_URL}/?chat=open`, chat: `${FRONTEND_URL}/?chat=open`,
settings: `${FRONTEND_URL}/users/${ settings: `${FRONTEND_URL}/settings`,
encodeURIComponent(reader)
}/~/settings`,
unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${ unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${
encodeURIComponent(token) encodeURIComponent(token)
}`, }`,
@@ -567,7 +581,7 @@ export async function sendDueDigests(now: Date = new Date()): Promise<number> {
to: row.email, to: row.email,
...email, ...email,
headers: { headers: {
"List-Unsubscribe": `<${PUBLIC_URL}/api/users/unsubscribe?token=${ "List-Unsubscribe": `<${PUBLIC_URL}/api/unsubscribe?token=${
encodeURIComponent(token) encodeURIComponent(token)
}>`, }>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",

View File

@@ -1,7 +1,8 @@
/** /**
* Posts, edits and deletes dumps, and reads them as each viewer may see them. * Posts, edits and deletes dumps, and reads them as each viewer may see them.
* `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail * `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail
* routes to read dumps. It broadcasts the changes to public dumps over the * routes to read dumps. A dump's slug is made from its title when it is
* posted, and changes only when an edit sends a new one (`slug-service.ts`). It broadcasts the changes to public dumps over the
* WebSocket. * WebSocket.
* *
* The functions that change a dump don't check who asks: the routes do first. * The functions that change a dump don't check who asks: the routes do first.
@@ -35,7 +36,7 @@ import {
notifyMentions, notifyMentions,
notifyUserFollowersNewDump, notifyUserFollowersNewDump,
} from "./notification-service.ts"; } from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { import {
DUMP_ALLOWED_MIME_PREFIXES, DUMP_ALLOWED_MIME_PREFIXES,
@@ -116,10 +117,10 @@ export async function createUrlDump(
const title = request.title?.trim() || richContent?.title || const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url); titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false; const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId);
const categoryIds = [...new Set(request.categoryIds ?? [])]; const categoryIds = [...new Set(request.categoryIds ?? [])];
transaction(() => { transaction(() => {
const slug = newSlug("dump", userId, title);
db.prepare( db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private) `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
@@ -139,21 +140,7 @@ export async function createUrlDump(
setDumpCategories(dumpId, categoryIds); setDumpCategories(dumpId, categoryIds);
}); });
const dump: Dump = { const dump: Dump = { ...fetchDump(dumpId), categoryIds };
id: dumpId,
kind: "url",
title,
slug,
comment: request.comment,
userId,
createdAt,
url: request.url,
richContent,
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds,
};
broadcastDumpChange(null, dump); broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
if (request.comment) { if (request.comment) {
@@ -195,7 +182,6 @@ export async function createFileDump(
const dumpId = crypto.randomUUID(); const dumpId = crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const finalTitle = title?.trim() || file.name; const finalTitle = title?.trim() || file.name;
const slug = makeSlug(finalTitle, dumpId);
const dedupedCategoryIds = [...new Set(categoryIds)]; const dedupedCategoryIds = [...new Set(categoryIds)];
await Deno.mkdir(DUMPS_DIR, { recursive: true }); await Deno.mkdir(DUMPS_DIR, { recursive: true });
@@ -205,6 +191,7 @@ export async function createFileDump(
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream()); await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream());
transaction(() => { transaction(() => {
const slug = newSlug("dump", userId, finalTitle);
db.prepare( db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
@@ -229,22 +216,7 @@ export async function createFileDump(
throw err; throw err;
} }
const dump: Dump = { const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds };
id: dumpId,
kind: "file",
title: finalTitle,
slug,
comment,
userId,
createdAt,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds: dedupedCategoryIds,
};
broadcastDumpChange(null, dump); broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
if (comment) { if (comment) {
@@ -286,15 +258,12 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
} }
/** /**
* Returns the dump with id or slug `idOrSlug`, without its categories, or * Returns dump `dumpId`, without its categories, or throws a 404. It doesn't
* throws a 404. It doesn't check privacy: the routes do, with {@link getDump}. * check privacy: the routes do, with {@link getDump}.
*/ */
function fetchDump(idOrSlug: string): Dump { function fetchDump(dumpId: string): Dump {
const row = UUID_RE.test(idOrSlug) const row = db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`)
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug) .get(dumpId);
: db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get(
idOrSlug,
);
if (!row || !isDumpRow(row)) { if (!row || !isDumpRow(row)) {
throw new APIException(404, "Dump not found"); throw new APIException(404, "Dump not found");
} }
@@ -302,8 +271,8 @@ function fetchDump(idOrSlug: string): Dump {
} }
/** /**
* Returns a dump, by id or slug, as `requestingUserId` may see it. The routes * Returns dump `dumpId` as `requestingUserId` may see it. The routes resolve
* resolve every `:dumpId` with it before acting on the dump. * every `:dumpId` with it before acting on the dump.
* *
* @throws {APIException} 404 when there is no such dump, or it is private and * @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs. * not theirs.
@@ -317,6 +286,25 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
return dump; return dump;
} }
/**
* Returns the dump of `userId`'s whose slug is or was `slug`, as
* `requestingUserId` may see it, for `GET /api/users/:key/dumps/:slug`, which
* reads a dump by its page's path. Reached by a former slug, it comes with
* its current one, which the client puts in its URL.
*
* @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs.
*/
export function getDumpByPath(
userId: string,
slug: string,
requestingUserId?: string,
): Dump {
const dumpId = resolveSlug("dump", userId, slug);
if (!dumpId) throw new APIException(404, "Dump not found");
return getDump(dumpId, requestingUserId);
}
/** /**
* Returns a page of the dumps `requestingUserId` may see whose title, note, or * Returns a page of the dumps `requestingUserId` may see whose title, note, or
* previewed title or description contains `query`, newest first, with how many * previewed title or description contains `query`, newest first, with how many
@@ -391,7 +379,7 @@ export function listDumps(
/** /**
* Returns the dumps `requestingUserId` may see that were posted with the same * Returns the dumps `requestingUserId` may see that were posted with the same
* link as `url`, newest first, for the create form's duplicate notice * link as `url`, newest first, for the create form's duplicate notice
* (`GET /api/dumps/by-url`). Links are compared in their canonical form, so * (`GET /api/dumps?url=`). Links are compared in their canonical form, so
* tracking parameters, the scheme, `www.` or a YouTube share format don't hide * tracking parameters, the scheme, `www.` or a YouTube share format don't hide
* a repost. * a repost.
*/ */
@@ -413,7 +401,7 @@ export function findDumpsByUrl(
LIMIT ?;`, LIMIT ?;`,
).all(canonical, requestingUserId ?? null, limit) as { ).all(canonical, requestingUserId ?? null, limit) as {
id: string; id: string;
slug: string | null; slug: string;
title: string; title: string;
created_at: string; created_at: string;
vote_count: number; vote_count: number;
@@ -423,9 +411,9 @@ export function findDumpsByUrl(
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
slug: row.slug ?? undefined, slug: row.slug,
title: row.title, title: row.title,
username: row.username, ownerUsername: row.username,
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
voteCount: row.vote_count, voteCount: row.vote_count,
commentCount: row.comment_count, commentCount: row.comment_count,
@@ -435,27 +423,38 @@ export function findDumpsByUrl(
/** /**
* Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its * Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its
* preview fetched and gives the dump its page's title, unless `request` sets * preview fetched and gives the dump its page's title, unless `request` sets
* one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a * one. A file dump ignores `url`. A new `slug` goes through `changeSlug`,
* which `exempt` (a moderator) frees from its rate limit; a new title leaves
* the slug as it is. Clients get the dump as `dump_updated`, or a
* `dump_deleted` when it turns private and a `dump_created` when it turns * `dump_deleted` when it turns private and a `dump_created` when it turns
* public. Anyone @mentioned in the note is notified. * public. Anyone @mentioned in the note is notified.
* *
* @throws {APIException} 404 when there is no such dump, 400 when a new URL * @throws {APIException} 404 when there is no such dump, 400 when a new URL
* isn't a public http(s) URL or a category doesn't exist. * isn't a public http(s) URL, a category doesn't exist or the slug isn't
* valid, 409 when the slug is taken, 429 when it changed too often lately.
*/ */
export async function updateDump( export async function updateDump(
dumpId: string, dumpId: string,
request: UpdateDumpRequest, request: UpdateDumpRequest,
exempt = false,
): Promise<Dump> { ): Promise<Dump> {
const dump = fetchDump(dumpId); const dump = fetchDump(dumpId);
if (request.categoryIds !== undefined) { if (request.categoryIds !== undefined) {
assertCategoriesExist(request.categoryIds); assertCategoriesExist(request.categoryIds);
} }
if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) {
throw new APIException(400, "Invalid URL");
}
// First, so that a refused slug leaves the rest of the dump unchanged.
if (request.slug !== undefined) {
changeSlug("dump", dump, request.slug, exempt);
}
const slug = request.slug ?? dump.slug;
const now = new Date(); const now = new Date();
if (dump.kind === "file") { if (dump.kind === "file") {
const title = request.title?.trim() || dump.title; const title = request.title?.trim() || dump.title;
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = { const updatedDump: Dump = {
...dump, ...dump,
title, title,
@@ -469,10 +468,9 @@ export async function updateDump(
updatedAt: now, updatedAt: now,
}; };
db.prepare( db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, `UPDATE dumps SET title = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run( ).run(
updatedDump.title, updatedDump.title,
updatedDump.slug ?? null,
updatedDump.comment ?? null, updatedDump.comment ?? null,
updatedDump.isPrivate ? 1 : 0, updatedDump.isPrivate ? 1 : 0,
now.toISOString(), now.toISOString(),
@@ -504,10 +502,6 @@ export async function updateDump(
const newUrl = request.url ?? dump.url!; const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) {
throw new APIException(400, "Invalid URL");
}
let { richContent, title } = dump; let { richContent, title } = dump;
if (newUrl !== dump.url) { if (newUrl !== dump.url) {
@@ -519,11 +513,10 @@ export async function updateDump(
title = request.title.trim(); title = request.title.trim();
} }
const newSlug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = { const updatedDump: Dump = {
...dump, ...dump,
title, title,
slug: newSlug, slug,
comment: "comment" in request comment: "comment" in request
? (request.comment ?? undefined) ? (request.comment ?? undefined)
: dump.comment, : dump.comment,
@@ -537,10 +530,9 @@ export async function updateDump(
const row = dumpApiToRow(updatedDump); const row = dumpApiToRow(updatedDump);
const result = db.prepare( const result = db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`, `UPDATE dumps SET title = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run( ).run(
row.title, row.title,
row.slug,
row.comment, row.comment,
row.url, row.url,
row.url ? canonicalizeUrl(row.url) : null, row.url ? canonicalizeUrl(row.url) : null,
@@ -581,14 +573,17 @@ export async function updateDump(
/** /**
* Replaces a file dump's upload and its comment, for * Replaces a file dump's upload and its comment, for
* `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left * `PUT /api/dumps/:dumpId/file`. A `title`, `slug`, `categoryIds` or
* out stays as it is. Visibility is taken here so that a new file and a change * `isPrivate` left out stays as it is. Visibility is taken here so that a new
* of visibility land together. The old file is put back if the database update * file and a change of visibility land together. The slug changes as in
* fails, and clients are told as {@link updateDump} tells them. * {@link updateDump}, before the file is written. The old file is put back if
* the database update fails, and clients are told as {@link updateDump} tells
* them.
* *
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a * @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* file dump, the title or comment is too long, the file's type isn't allowed, * file dump, the title or comment is too long, the file's type isn't allowed,
* it exceeds 50 MB, or a category doesn't exist. * it exceeds 50 MB, a category doesn't exist or the slug isn't valid, 409 when
* the slug is taken, 429 when it changed too often lately.
*/ */
export async function replaceFileDump( export async function replaceFileDump(
dumpId: string, dumpId: string,
@@ -597,6 +592,8 @@ export async function replaceFileDump(
title?: string, title?: string,
categoryIds?: string[], categoryIds?: string[],
isPrivate?: boolean, isPrivate?: boolean,
slug?: string,
exempt = false,
): Promise<Dump> { ): Promise<Dump> {
assertFileDumpText(title, comment); assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) { if (!isAllowedMime(file.type)) {
@@ -611,6 +608,7 @@ export async function replaceFileDump(
throw new APIException(400, "Not a file dump"); throw new APIException(400, "Not a file dump");
} }
if (categoryIds !== undefined) assertCategoriesExist(categoryIds); if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
if (slug !== undefined) changeSlug("dump", dump, slug, exempt);
// The new file is written beside the old one and renamed over it (atomic: a // The new file is written beside the old one and renamed over it (atomic: a
// reader sees one or the other). A hard link keeps the old one reachable so // reader sees one or the other). A hard link keeps the old one reachable so
@@ -633,17 +631,13 @@ export async function replaceFileDump(
const now = new Date(); const now = new Date();
const finalTitle = title?.trim() || dump.title; const finalTitle = title?.trim() || dump.title;
const newSlug = finalTitle !== dump.title
? makeSlug(finalTitle, dumpId)
: dump.slug;
const finalPrivate = isPrivate ?? dump.isPrivate; const finalPrivate = isPrivate ?? dump.isPrivate;
try { try {
transaction(() => { transaction(() => {
db.prepare( db.prepare(
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, `UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run( ).run(
finalTitle, finalTitle,
newSlug ?? null,
file.name, file.name,
file.type, file.type,
file.size, file.size,
@@ -680,7 +674,7 @@ export async function replaceFileDump(
const updatedDump: Dump = { const updatedDump: Dump = {
...dump, ...dump,
title: finalTitle, title: finalTitle,
slug: newSlug, slug: slug ?? dump.slug,
fileName: file.name, fileName: file.name,
fileMime: file.type, fileMime: file.type,
fileSize: file.size, fileSize: file.size,
@@ -694,7 +688,7 @@ export async function replaceFileDump(
/** /**
* Returns a page of `userId`'s dumps, newest first, for * Returns a page of `userId`'s dumps, newest first, for
* `GET /api/users/:username/dumps`. The route sets `includePrivate` when users * `GET /api/users/:key/dumps`. The route sets `includePrivate` when users
* ask for their own. * ask for their own.
* *
* @throws {APIException} 500 when a row doesn't parse as a dump. * @throws {APIException} 500 when a row doesn't parse as a dump.
@@ -720,7 +714,7 @@ export function getDumpsByUser(
/** /**
* Returns a page of the dumps `userId` upvoted, latest vote first, for * Returns a page of the dumps `userId` upvoted, latest vote first, for
* `GET /api/users/:username/votes`. Their own private dumps are included only * `GET /api/users/:key/upvoted`. Their own private dumps are included only
* when they are the one asking. * when they are the one asking.
* *
* @throws {APIException} 500 when a row doesn't parse as a dump. * @throws {APIException} 500 when a row doesn't parse as a dump.
@@ -763,7 +757,7 @@ export function getVotedDumpsByUser(
/** /**
* Fetches a URL dump's preview again, bypassing the cache, and replaces its * Fetches a URL dump's preview again, bypassing the cache, and replaces its
* title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`. * title and preview, for `POST /api/dumps/:dumpId/metadata`. The slug stays.
* *
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a * @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* URL dump, 502 when the page can't be reached. * URL dump, 502 when the page can't be reached.
@@ -808,9 +802,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
* @throws {APIException} 404 when there is no such dump. * @throws {APIException} 404 when there is no such dump.
*/ */
export function getDumpThumbnailInfo( export function getDumpThumbnailInfo(
idOrSlug: string, dumpId: string,
): { id: string; mime: string } | undefined { ): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug); const dump = fetchDump(dumpId);
return dump.thumbnailMime return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime } ? { id: dump.id, mime: dump.thumbnailMime }
: undefined; : undefined;

View File

@@ -127,7 +127,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
/** /**
* Returns the ids of every user and playlist `followerId` follows, private * Returns the ids of every user and playlist `followerId` follows, private
* playlists included, for `GET /api/follows/status`. * playlists included, for `GET /api/me/follow`.
* *
* @throws {APIException} 500 when a row doesn't parse. * @throws {APIException} 500 when a row doesn't parse.
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Makes invites, and checks and claims them at sign-up. An invite works once, * Makes invites, and checks and claims them at sign-up. An invite works once,
* for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and * for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and
* checks them, and `POST /api/users/register` claims one. * checks them, and `POST /api/users` claims one.
* *
* @module * @module
*/ */

View File

@@ -3,7 +3,9 @@
* `notification_created`, and reads them back for `routes/notifications.ts`. * `notification_created`, and reads them back for `routes/notifications.ts`.
* The services of follows, votes, likes, comments, dumps, playlists and the * The services of follows, votes, likes, comments, dumps, playlists and the
* chat call its `notify…` triggers. Every trigger passes a source key, so the * chat call its `notify…` triggers. Every trigger passes a source key, so the
* same event never notifies a user twice. * same event never notifies a user twice. What a notification names by id is
* joined each time it is sent ({@link withCurrentRefs}), so its names and
* links follow renames.
* *
* @module * @module
*/ */
@@ -24,6 +26,58 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.ts"; import { sendToUser } from "./ws-service.ts";
import { mentionedUsernames } from "../../shared/mention.ts"; import { mentionedUsernames } from "../../shared/mention.ts";
/** Each id of a user a notification names, beside the field of their name. */
const USER_FIELDS = [
["followerId", "followerUsername"],
["dumperId", "dumperUsername"],
["voterId", "voterUsername"],
["mentionerId", "mentionerUsername"],
["commenterId", "commenterUsername"],
["likerId", "likerUsername"],
] as const;
/**
* Returns `data` with what may have changed since it was stored joined from
* the database: each user's current name, and the slug and owner of its dump
* and playlist, which the client builds their paths from. A user since deleted
* keeps their stored name; a deleted dump or playlist gets no ref, and so no
* link. Every notification the server sends goes through it, and so do the
* digests.
*/
export function withCurrentRefs(data: NotificationData): NotificationData {
const fields = data as unknown as Record<string, string | undefined>;
const out: Record<string, string | undefined> = { ...fields };
for (const [idField, nameField] of USER_FIELDS) {
const id = fields[idField];
if (!id) continue;
const row = db.prepare(`SELECT username FROM users WHERE id = ?;`).get(
id,
) as { username: string } | undefined;
if (row) out[nameField] = row.username;
}
const context = fields.contextType;
const dumpId = fields.dumpId ??
(context === "dump" ? fields.contextId : undefined);
const playlistId = fields.playlistId ??
(context === "playlist" ? fields.contextId : undefined);
const ref = (table: string, id: string) =>
db.prepare(
`SELECT t.slug, u.username FROM ${table} t JOIN users u ON u.id = t.user_id
WHERE t.id = ?;`,
).get(id) as { slug: string; username: string } | undefined;
if (dumpId) {
const row = ref("dumps", dumpId);
out.dumpSlug = row?.slug;
out.dumpOwnerUsername = row?.username;
}
if (playlistId) {
const row = ref("playlists", playlistId);
out.playlistSlug = row?.slug;
out.playlistOwnerUsername = row?.username;
}
return out as unknown as NotificationData;
}
/** /**
* Stores a notification for `userId` and pushes it live. With a `sourceKey`, * Stores a notification for `userId` and pushes it live. With a `sourceKey`,
* a second one for the same `(userId, sourceKey)` is neither stored nor sent. * a second one for the same `(userId, sourceKey)` is neither stored nor sent.
@@ -62,7 +116,7 @@ function createNotification(
id, id,
userId, userId,
type, type,
data, data: withCurrentRefs(data),
read: false, read: false,
createdAt: now, createdAt: now,
}, },
@@ -71,8 +125,8 @@ function createNotification(
} }
/** /**
* Returns a page of `userId`'s notifications, newest first, for * Returns a page of `userId`'s notifications, newest first, with their refs
* `GET /api/notifications`. * joined, for `GET /api/me/notifications`.
* *
* @throws {APIException} 500 when a row doesn't parse. * @throws {APIException} 500 when a row doesn't parse.
*/ */
@@ -89,7 +143,13 @@ export function getNotificationsForUser(
if (!rawRows.every(isNotificationRow)) { if (!rawRows.every(isNotificationRow)) {
throw new APIException(500, "Malformed notification data"); throw new APIException(500, "Malformed notification data");
} }
return pageOf(rawRows.map(notificationRowToApi), limit); return pageOf(
rawRows.map(notificationRowToApi).map((n) => ({
...n,
data: withCurrentRefs(n.data),
})),
limit,
);
} }
/** /**
@@ -195,6 +255,7 @@ export function notifyUserFollowersNewDump(
dumpTitle, dumpTitle,
}; };
const dataJson = JSON.stringify(data); const dataJson = JSON.stringify(data);
const sent = withCurrentRefs(data);
const now = new Date(); const now = new Date();
const createdAt = now.toISOString(); const createdAt = now.toISOString();
const sourceKey = `dump:${dumpId}`; const sourceKey = `dump:${dumpId}`;
@@ -227,7 +288,7 @@ export function notifyUserFollowersNewDump(
id, id,
userId: row.follower_id, userId: row.follower_id,
type: "user_dump_posted", type: "user_dump_posted",
data, data: sent,
read: false, read: false,
createdAt: now, createdAt: now,
}, },

View File

@@ -1,7 +1,7 @@
/** /**
* Emails password reset links and sets the new password when one is used. The * Emails password reset links and sets the new password when one is used.
* users routes call it for `POST /api/users/request-password-reset` and * `routes/password-resets.ts` calls it for `POST /api/password-resets` and
* `POST /api/users/reset-password`; the link opens the client's * `PUT /api/password-resets/:token`; the link opens the client's
* `/reset-password` page. * `/reset-password` page.
* *
* @module * @module

View File

@@ -34,7 +34,7 @@ import {
notifyMentions, notifyMentions,
notifyPlaylistFollowersNewDump, notifyPlaylistFollowersNewDump,
} from "./notification-service.ts"; } from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { import {
isMemberVisibleTo, isMemberVisibleTo,
@@ -50,25 +50,34 @@ import { parseRows } from "../model/rows.ts";
*/ */
const PLAYLIST_SELECT = `p.*, u.username as owner_username, const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; FROM playlists p JOIN users u ON u.id = p.user_id`;
/** /**
* Returns the playlist with id or slug `idOrSlug`, private or not. A caller * Returns playlist `playlistId`, private or not. A caller that shows it to
* that shows it to someone must check `isPublic` first, as the OG middleware * someone must check `isPublic` first, as the OG middleware does.
* does.
* *
* @throws {APIException} 404 when there is no such playlist. * @throws {APIException} 404 when there is no such playlist.
*/ */
export function getPlaylistById(idOrSlug: string): Playlist { export function getPlaylistById(playlistId: string): Playlist {
const row = UUID_RE.test(idOrSlug) const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) .get(playlistId);
: db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug);
if (!row || !isPlaylistRow(row)) { if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found"); throw new APIException(404, "Playlist not found");
} }
return playlistRowToApi(row); return playlistRowToApi(row);
} }
/**
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
* slug, it comes with its current one.
*/
export function getPlaylistByPath(userId: string, slug: string): Playlist {
const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found");
return getPlaylistById(playlistId);
}
/** /**
* Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is * Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is
* public, it notifies the users its description mentions. It links the * public, it notifies the users its description mentions. It links the
@@ -81,28 +90,21 @@ export function createPlaylist(
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const title = req.title.trim(); const title = req.title.trim();
const slug = makeSlug(title, id); transaction(() => {
db.prepare( db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run( ).run(
id, id,
userId, userId,
title, title,
slug, newSlug("playlist", userId, title),
req.description ?? null, req.description ?? null,
req.isPublic ? 1 : 0, req.isPublic ? 1 : 0,
createdAt.toISOString(), createdAt.toISOString(),
); );
const playlist: Playlist = { });
id, const playlist = getPlaylistById(id);
userId,
title,
slug,
description: req.description,
isPublic: req.isPublic,
createdAt,
};
if (req.description) { if (req.description) {
// A private playlist's mentions are notified if it goes public. // A private playlist's mentions are notified if it goes public.
if (req.isPublic) { if (req.isPublic) {
@@ -116,17 +118,20 @@ export function createPlaylist(
/** /**
* Returns a playlist with the dumps `requestingUserId` may see in it, in * Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a * order, for `GET /api/playlists/:playlistId`, and for
* signed-out viewer. * `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
* path. A null `requestingUserId` is a signed-out viewer.
* *
* @throws {APIException} 404 when there is no such playlist, or it is private * @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs. * and not theirs.
*/ */
export function getPlaylist( export function getPlaylist(
playlistId: string, playlistId: string | Playlist,
requestingUserId: string | null, requestingUserId: string | null,
): PlaylistWithDumps { ): PlaylistWithDumps {
const playlist = getPlaylistById(playlistId); const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId)
: playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) { if (!playlist.isPublic && requestingUserId !== playlist.userId) {
throw new APIException(404, "Playlist not found"); throw new APIException(404, "Playlist not found");
@@ -176,7 +181,7 @@ export function searchPlaylists(
/** /**
* Returns a page of `userId`'s playlists, newest first, for * Returns a page of `userId`'s playlists, newest first, for
* `GET /api/users/:username/playlists`. The private ones are listed only when * `GET /api/users/:key/playlists`. The private ones are listed only when
* `requestingUserId` is `userId`. * `requestingUserId` is `userId`.
*/ */
export function listPlaylistsByUser( export function listPlaylistsByUser(
@@ -198,13 +203,16 @@ export function listPlaylistsByUser(
/** /**
* Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns
* it as it now is. The slug follows the title. `canModerate` lets someone * it as it now is. A new `slug` goes through `changeSlug`, first, and a
* other than the owner edit it. If the playlist is public, it notifies, in the * moderator is exempt from its rate limit; a new title leaves the slug as it
* owner's name, the users the description mentions. It links the description's * is. `canModerate` lets someone other than the owner edit it. If the
* attachments and broadcasts `playlist_updated`. * playlist is public, it notifies, in the owner's name, the users the
* description mentions. It links the description's attachments and
* broadcasts `playlist_updated`.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator. * is neither its owner nor a moderator, 400 when the slug isn't valid, 409
* when it is taken, 429 when it changed too often lately.
*/ */
export function updatePlaylist( export function updatePlaylist(
playlistId: string, playlistId: string,
@@ -226,13 +234,15 @@ export function updatePlaylist(
? req.isPublic ? req.isPublic
: playlist.isPublic; : playlist.isPublic;
if (req.slug !== undefined) {
changeSlug("playlist", playlist, req.slug, canModerate);
}
const now = new Date(); const now = new Date();
const newSlug = makeSlug(newTitle, playlist.id);
db.prepare( db.prepare(
`UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, `UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run( ).run(
newTitle, newTitle,
newSlug,
newDescription, newDescription,
newIsPublic ? 1 : 0, newIsPublic ? 1 : 0,
now.toISOString(), now.toISOString(),
@@ -242,7 +252,7 @@ export function updatePlaylist(
const updated: Playlist = { const updated: Playlist = {
...playlist, ...playlist,
title: newTitle, title: newTitle,
slug: newSlug, slug: req.slug ?? playlist.slug,
description: newDescription ?? undefined, description: newDescription ?? undefined,
isPublic: newIsPublic, isPublic: newIsPublic,
updatedAt: now, updatedAt: now,
@@ -487,8 +497,8 @@ export function reorderPlaylist(
/** /**
* Returns each of `userId`'s playlists, newest first, and whether it holds * Returns each of `userId`'s playlists, newest first, and whether it holds
* dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The * dump `dumpId`, for `GET /api/me/playlists?contains=`. The client's
* client's add-to-playlist modal shows it. The playlists have no dump count. * add-to-playlist modal shows it.
* *
* @throws {APIException} 500 when a row doesn't parse as a playlist. * @throws {APIException} 500 when a row doesn't parse as a playlist.
*/ */
@@ -497,9 +507,11 @@ export function getPlaylistMembershipsForDump(
userId: string, userId: string,
): PlaylistMembership[] { ): PlaylistMembership[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT p.*, pd.dump_id IS NOT NULL as has_dump `SELECT p.*, u.username as owner_username,
FROM playlists p (SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count,
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ? EXISTS (SELECT 1 FROM playlist_dumps pd
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.user_id = ? WHERE p.user_id = ?
ORDER BY p.created_at DESC;`, ORDER BY p.created_at DESC;`,
).all(dumpId, userId); ).all(dumpId, userId);
@@ -536,7 +548,7 @@ export function getPlaylistsForDump(
} }
/** /**
* Returns what `GET /api/playlists/:playlistId/image` needs to serve a * Returns what `GET /api/playlists/:playlistId/cover` needs to serve a
* playlist's cover, or undefined when it has none. The route serves a private * playlist's cover, or undefined when it has none. The route serves a private
* playlist's cover as private, so no shared cache keeps it. * playlist's cover as private, so no shared cache keeps it.
* *
@@ -544,10 +556,10 @@ export function getPlaylistsForDump(
* and `requestingUserId` isn't its owner. * and `requestingUserId` isn't its owner.
*/ */
export function getPlaylistImageInfo( export function getPlaylistImageInfo(
idOrSlug: string, playlistId: string,
requestingUserId: string | null, requestingUserId: string | null,
): { id: string; imageMime: string; isPublic: boolean } | undefined { ): { id: string; imageMime: string; isPublic: boolean } | undefined {
const playlist = getPlaylistById(idOrSlug); const playlist = getPlaylistById(playlistId);
if (!playlist.isPublic && requestingUserId !== playlist.userId) { if (!playlist.isPublic && requestingUserId !== playlist.userId) {
throw new APIException(404, "Playlist not found"); throw new APIException(404, "Playlist not found");
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Fetches a link's preview through the provider that knows its site, and * Fetches a link's preview through the provider that knows its site, and
* reuses it for a few minutes. `dump-service.ts` calls it when a link is * reuses it for a few minutes. `dump-service.ts` calls it when a link is
* posted, changed or refreshed, and `GET /api/preview` when the create form * posted, changed or refreshed, and `GET /api/remote/preview` when the create form
* previews one. * previews one.
* *
* @module * @module
@@ -29,7 +29,7 @@ const providers: RichContentProvider[] = [
]; ];
/** /**
* Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports * Returned by {@link tryFetchRichContent} to `GET /api/remote/preview`, which reports
* `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is * `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is
* false. * false.
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Reads and writes each user's settings in `user_settings`. The users routes * Reads and writes each user's settings in `user_settings`. `routes/me.ts`
* answer `GET` and `PATCH /api/users/me/settings` with it, and the digests read * answers `GET` and `PATCH /api/me/settings` with it, and the digests read
* their readers' choices from the same table. * their readers' choices from the same table.
* *
* @module * @module

View File

@@ -0,0 +1,91 @@
/**
* Changes and resolves the slugs of dumps and playlists. A slug changes only
* when someone edits it (the dump and playlist services call
* {@link changeSlug} with the slug their edit form sent), and the slug it
* leaves becomes an alias of the item, so that old links still reach it; the
* routes that read an item by its page path resolve the slug with
* {@link resolveSlug}, aliases included, and answer the item, whose current
* slug the client then puts in its URL.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import {
SLUG_TABLES,
slugHolder,
type SlugKind,
uniqueSlug,
validateSlug,
} from "../lib/slugify.ts";
import { slugify } from "../../shared/slug.ts";
import { RENAME_LIMITS } from "../../shared/validation.ts";
/**
* Returns a free slug for a new item of `userId`'s titled `title`, for the
* dump and playlist services to insert it with, inside their transaction.
*/
export function newSlug(kind: SlugKind, userId: string, title: string): string {
return uniqueSlug(db, kind, userId, slugify(title));
}
/**
* Returns the id of `userId`'s item of `kind` whose slug is or was `slug`, or
* undefined when none is.
*/
export function resolveSlug(
kind: SlugKind,
userId: string,
slug: string,
): string | undefined {
return slugHolder(db, kind, userId, slug);
}
/**
* Gives `item` the slug `slug`, keeping its current one as an alias. An alias
* always names the item itself, so redirects never chain; taking back one of
* the item's own aliases swaps it with the current slug. Unless `exempt` (a
* moderator), an item's slug changes at most `RENAME_LIMITS.SLUG.count` times
* in its window, counted from its aliases. A slug equal to the current one
* changes nothing.
*
* @throws {APIException} 400 when `slug` isn't valid, 409 when another item
* of the owner's holds it now or held it before, 429 when the item's slug
* changed too often lately.
*/
export function changeSlug(
kind: SlugKind,
item: { id: string; userId: string; slug: string },
slug: string,
exempt: boolean,
): void {
if (slug === item.slug) return;
validateSlug(slug);
const { items, aliases, itemId } = SLUG_TABLES[kind];
const now = new Date();
transaction(() => {
const holder = slugHolder(db, kind, item.userId, slug);
if (holder && holder !== item.id) {
throw new APIException(409, `The slug "${slug}" is already taken`);
}
const { count, windowMs } = RENAME_LIMITS.SLUG;
const recent = (db.prepare(
`SELECT COUNT(*) AS n FROM ${aliases} WHERE ${itemId} = ? AND created_at > ?;`,
).get(item.id, new Date(now.getTime() - windowMs).toISOString()) as {
n: number;
}).n;
if (!exempt && recent >= count) {
throw new APIException(
429,
`A slug can only be changed ${count} times a day`,
);
}
db.prepare(`DELETE FROM ${aliases} WHERE user_id = ? AND slug = ?;`)
.run(item.userId, slug);
db.prepare(
`INSERT INTO ${aliases} (user_id, slug, ${itemId}, created_at) VALUES (?, ?, ?, ?);`,
).run(item.userId, item.slug, item.id, now.toISOString());
db.prepare(`UPDATE ${items} SET slug = ? WHERE id = ?;`).run(slug, item.id);
});
}

View File

@@ -23,7 +23,7 @@ import {
userApiToRow, userApiToRow,
userRowToApi, userRowToApi,
} from "../model/user.ts"; } from "../model/user.ts";
import { db } from "../db/connection.ts"; import { db, transaction } from "../db/connection.ts";
import { closeUserSockets } from "./ws-service.ts"; import { closeUserSockets } from "./ws-service.ts";
import { hashPassword } from "../lib/password.ts"; import { hashPassword } from "../lib/password.ts";
@@ -32,6 +32,11 @@ import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts"; import { parseRows } from "../model/rows.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { renderWelcomeEmail } from "../mail/welcome.ts"; import { renderWelcomeEmail } from "../mail/welcome.ts";
import {
RENAME_LIMITS,
USERNAME_RE,
VALIDATION,
} from "../../shared/validation.ts";
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
const USER_SELECT = const USER_SELECT =
@@ -40,13 +45,34 @@ const USER_SELECT =
FROM users u FROM users u
LEFT JOIN users i ON i.id = u.invited_by`; LEFT JOIN users i ON i.id = u.invited_by`;
/**
* Throws a 409 when `username` is, in any case, the name or a former name of
* an account other than `exceptUserId`. A former name stays its user's for
* good, so that old links and old @mentions can't be taken over.
*/
function assertUsernameAvailable(
username: string,
exceptUserId?: string,
): void {
const row = db.prepare(
`SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?
UNION ALL
SELECT user_id FROM user_aliases
WHERE lower(username) = lower(?) AND user_id != ?
LIMIT 1;`,
).get(username, exceptUserId ?? "", username, exceptUserId ?? "");
if (row) {
throw new APIException(409, "Username already exists");
}
}
/** /**
* Creates a user with the `user` role, invited by `inviterId`, for * Creates a user with the `user` role, invited by `inviterId`, for
* `POST /api/users/register`. The caller has validated `request` and claimed * `POST /api/users`. The caller has validated `request` and claimed the
* the invite. * invite.
* *
* @throws {APIException} 409 when the username or the email is taken, in any * @throws {APIException} 409 when the username or the email is taken, in any
* case. * case, or the username is someone's former name.
*/ */
export async function createUser( export async function createUser(
request: RegisterUserRequest, request: RegisterUserRequest,
@@ -55,14 +81,7 @@ export async function createUser(
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const existingUser = db.prepare( assertUsernameAvailable(request.username);
"SELECT id FROM users WHERE lower(username) = lower(?);",
).get(request.username);
if (existingUser) {
throw new APIException(409, "Username already exists");
}
assertEmailAvailable(request.email); assertEmailAvailable(request.email);
const passwordHash = await hashPassword(request.password); const passwordHash = await hashPassword(request.password);
@@ -144,14 +163,14 @@ export function getSessionState(
} }
/** /**
* Returns the user named `username`, with their hash and email. The name * Returns the user named `username` now, in any case, with their hash and
* matches case-sensitively. * email. Signing in takes it: a former name doesn't sign in.
* *
* @throws {APIException} 404 when there is no such user. * @throws {APIException} 404 when there is no such user.
*/ */
export function getUserByUsername(username: string): User { export function getUserByUsername(username: string): User {
const userRow = db.prepare( const userRow = db.prepare(
`${USER_SELECT} WHERE u.username = ?`, `${USER_SELECT} WHERE lower(u.username) = lower(?)`,
).get(username); ).get(username);
if (!userRow || !isUserRow(userRow)) { if (!userRow || !isUserRow(userRow)) {
@@ -161,6 +180,90 @@ export function getUserByUsername(username: string): User {
return userRowToApi(userRow); return userRowToApi(userRow);
} }
/**
* Returns the user who is or was named `username`, in any case: a page or an
* API path naming a user by a former name reaches them, and the caller sends
* the client on to the current one.
*
* @throws {APIException} 404 when nobody is or was.
*/
export function resolveUsername(username: string): User {
const alias = db.prepare(
`SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`,
).get(username) as { user_id: string } | undefined;
return alias ? getUserById(alias.user_id) : getUserByUsername(username);
}
/**
* Returns the user an API path's key names: `@name` by their name, current or
* former ({@link resolveUsername}), anything else by their id. Every route
* under `/api/users/:key` resolves its key with it.
*
* @throws {APIException} 404 when there is no such user.
*/
export function resolveUserKey(key: string): User {
return key.startsWith("@") ? resolveUsername(key.slice(1)) : getUserById(key);
}
/**
* Renames user `userId` to `username`, for `PATCH /api/me` and, by a
* moderator, `PATCH /api/users/:key/username`, and returns them renamed. The
* old name becomes an alias of theirs for good, so `/@old/…` still reaches
* them and nobody else may take it; a change of case alone leaves none, since
* names are looked up in any case. Taking back a former name of one's own is
* allowed. Their dumps' and playlists' slugs belong to their id, so their
* paths follow the new name. Unless `exempt` (a moderator), a user may change
* names once in `RENAME_LIMITS.USERNAME`'s window.
*
* @throws {APIException} 400 when `username` isn't a valid name, 404 when
* there is no such user, 409 when the name is or was someone else's, 429 when
* the user changed names too recently.
*/
export function renameUser(
userId: string,
username: string,
exempt: boolean,
): User {
if (!USERNAME_RE.test(username)) {
throw new APIException(
400,
`Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`,
);
}
const user = getUserById(userId);
if (username === user.username) return user;
const now = new Date();
transaction(() => {
assertUsernameAvailable(username, userId);
if (username.toLowerCase() !== user.username.toLowerCase()) {
const { count, windowMs } = RENAME_LIMITS.USERNAME;
const recent = (db.prepare(
`SELECT COUNT(*) AS n FROM user_aliases WHERE user_id = ? AND created_at > ?;`,
).get(userId, new Date(now.getTime() - windowMs).toISOString()) as {
n: number;
}).n;
if (!exempt && recent >= count) {
throw new APIException(
429,
`A username can only be changed once every ${
windowMs / 86_400_000
} days`,
);
}
db.prepare(
`DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`,
).run(userId, username);
db.prepare(
`INSERT INTO user_aliases (username, user_id, created_at) VALUES (?, ?, ?);`,
).run(user.username, userId, now.toISOString());
}
db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`)
.run(username, now.toISOString(), userId);
});
return getUserById(userId);
}
/** /**
* Throws a 409 when an account other than `exceptUserId` has `email`, in any * Throws a 409 when an account other than `exceptUserId` has `email`, in any
* case. Password resets are looked up by email, so an address must belong to * case. Password resets are looked up by email, so an address must belong to
@@ -201,7 +304,7 @@ export function getUserByEmail(email: string): User | null {
/** /**
* Returns up to `limit` users whose name starts with `query`, for the editor's * Returns up to `limit` users whose name starts with `query`, for the editor's
* @mention suggestions (`GET /api/users/search`). An empty query returns none. * @mention suggestions (`GET /api/users?q=`). An empty query returns none.
*/ */
export function searchUsers( export function searchUsers(
query: string, query: string,
@@ -237,7 +340,9 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
/** /**
* Applies `request` to user `userId` and returns the updated user. It trusts * Applies `request` to user `userId` and returns the updated user. It trusts
* `request`: the routes pick the fields a caller may change. A new password * `request`: the routes pick the fields a caller may change. The username
* changes through {@link renameUser} alone, which keeps the old one as an
* alias, so a `username` here is ignored. A new password
* also ends every session opened before it and closes the user's sockets, so * also ends every session opened before it and closes the user's sockets, so
* the caller must issue fresh tokens if the user is to stay signed in. A * the caller must issue fresh tokens if the user is to stay signed in. A
* `description` of null keeps the current one. * `description` of null keeps the current one.
@@ -251,7 +356,8 @@ export async function updateUser(
): Promise<User> { ): Promise<User> {
const user = getUserById(userId); const user = getUserById(userId);
const { password, description, email, ...requestFields } = request; const { password, description, email, username: _, ...requestFields } =
request;
if (email !== undefined && email !== user.email) { if (email !== undefined && email !== user.email) {
assertEmailAvailable(email, userId); assertEmailAvailable(email, userId);
} }
@@ -271,9 +377,8 @@ export async function updateUser(
let userResult; let userResult;
try { try {
userResult = db.prepare( userResult = db.prepare(
`UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, `UPDATE users SET password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`,
).run( ).run(
updatedUserRow.username,
updatedUserRow.password_hash, updatedUserRow.password_hash,
updatedUserRow.role, updatedUserRow.role,
updatedUserRow.description, updatedUserRow.description,
@@ -306,7 +411,7 @@ export async function updateUser(
/** /**
* Records that user `userId` now has an avatar of type `mime`, once * Records that user `userId` now has an avatar of type `mime`, once
* `POST /api/avatars/me` has written the file. It bumps `updated_at`, which * `PUT /api/me/avatar` has written the file. It bumps `updated_at`, which
* the client uses to reload the image. * the client uses to reload the image.
* *
* @throws {APIException} 404 when there is no such user. * @throws {APIException} 404 when there is no such user.
@@ -324,7 +429,7 @@ export function updateUserAvatar(userId: string, mime: string): void {
/** /**
* Returns everyone `userId` invited, and everyone they invited in turn, eleven * Returns everyone `userId` invited, and everyone they invited in turn, eleven
* levels deep at most, in sign-up order, for the profile page's invite tree * levels deep at most, in sign-up order, for the profile page's invite tree
* (`GET /api/users/:username/invitees`). * (`GET /api/users/:key/invitees`).
*/ */
export function getInviteTree(userId: string): Invitee[] { export function getInviteTree(userId: string): Invitee[] {
const rows = db.prepare(` const rows = db.prepare(`

View File

@@ -83,6 +83,18 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
broadcastPresence(); broadcastPresence();
} }
/**
* Records `userId`'s new name on their clients, so presence and the typing
* list show it, and rebroadcasts presence. The routes that rename a user call
* it, along with `broadcastUserUpdated`.
*/
export function updateClientUsername(userId: string, username: string): void {
for (const client of clients) {
if (client.userId === userId) client.username = username;
}
broadcastPresence();
}
/** /**
* Returns each signed-in user with a socket open, once. The route sends it in * Returns each signed-in user with a socket open, once. The route sends it in
* `welcome`, and {@link broadcastPresence} in `presence_update`. * `welcome`, and {@link broadcastPresence} in `presence_update`.

View File

@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
.get() as { .get() as {
n: number; n: number;
}; };
assertEquals(applied.n, 20); assertEquals(applied.n, 21);
// Running again applies nothing. // Running again applies nothing.
runMigrations(db); runMigrations(db);
assertEquals( assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number; n: number;
}).n, }).n,
20, 21,
); );
db.close(); db.close();
}); });

View File

@@ -0,0 +1,168 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0021 } from "../../../db/migrations/0021_page_urls.ts";
/** A database as v3 left it: schema.sql without 0021's tables and indexes. */
function v3Db(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(
Deno.readTextFileSync(new URL("../../../db/schema.sql", import.meta.url)),
);
db.exec(`
DROP TABLE dump_slug_aliases;
DROP TABLE playlist_slug_aliases;
DROP TABLE user_aliases;
DROP INDEX idx_dumps_user_slug;
DROP INDEX idx_playlists_user_slug;
CREATE INDEX idx_dumps_slug ON dumps(slug);
CREATE INDEX idx_playlists_slug ON playlists(slug);
`);
return db;
}
const NOW = "2024-01-01T00:00:00.000Z";
const later = (n: number) => new Date(Date.parse(NOW) + n * 1000).toISOString();
function user(db: DatabaseSync, username: string): string {
const id = crypto.randomUUID();
db.prepare(
`INSERT INTO users (id, username, password_hash, created_at, email) VALUES (?, ?, 'x', ?, ?);`,
).run(id, username, NOW, `${username}@example.test`);
return id;
}
/** Inserts a dump or playlist with its v3 slug, and returns its id and slug. */
function item(
db: DatabaseSync,
table: "dumps" | "playlists",
userId: string,
title: string,
at: number,
): { id: string; v3: string } {
const id = crypto.randomUUID();
const v3 = `${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${
id.slice(0, 8)
}`;
db.prepare(
table === "dumps"
? `INSERT INTO dumps (id, kind, title, slug, user_id, created_at, url) VALUES (?, 'url', ?, ?, ?, ?, 'https://x.test');`
: `INSERT INTO playlists (id, title, slug, user_id, created_at) VALUES (?, ?, ?, ?, ?);`,
).run(id, title, v3, userId, later(at));
return { id, v3 };
}
const slugOf = (db: DatabaseSync, table: string, id: string) =>
(db.prepare(`SELECT slug FROM ${table} WHERE id = ?;`).get(id) as {
slug: string;
}).slug;
Deno.test("migration 0021: bare slugs unique per owner, reserved categories renamed", () => {
const db = v3Db();
const bob = user(db, "Bob");
const ann = user(db, "ann");
const first = item(db, "dumps", bob, "Never Gonna", 0);
const second = item(db, "dumps", bob, "Never gonna!", 1);
const anns = item(db, "dumps", ann, "Never Gonna", 2);
const mix = item(db, "playlists", bob, "Never Gonna", 3);
for (
const [slug, name] of [
["hot", "Hot"],
["hot-2", "Hot 2"],
["follow", "F"],
["music", "M"],
]
) {
db.prepare(
`INSERT INTO categories (id, slug, name, created_at) VALUES (?, ?, ?, ?);`,
).run(crypto.randomUUID(), slug, name, NOW);
}
up0021(db);
// The oldest keeps the bare slug; each kind and owner is its own space.
assertEquals(slugOf(db, "dumps", first.id), "never-gonna");
assertEquals(slugOf(db, "dumps", second.id), "never-gonna-2");
assertEquals(slugOf(db, "dumps", anns.id), "never-gonna");
assertEquals(slugOf(db, "playlists", mix.id), "never-gonna");
assertEquals(
(db.prepare(`SELECT slug FROM categories ORDER BY slug;`).all() as {
slug: string;
}[]).map((r) => r.slug),
["follow-2", "hot-2", "hot-3", "music"],
);
// The new index holds.
let clash = false;
try {
db.prepare(`UPDATE dumps SET slug = 'never-gonna' WHERE id = ?;`).run(
second.id,
);
} catch {
clash = true;
}
assertEquals(clash, true);
db.close();
});
Deno.test("migration 0021: v3 links in notes, comments and chat become v4 paths", () => {
const db = v3Db();
const bob = user(db, "Bob");
const dump = item(db, "dumps", bob, "Never Gonna", 0);
const mix = item(db, "playlists", bob, "Sunday Mix", 1);
const text = [
`[by slug](http://gerbeur.test/dumps/${dump.v3}#comment-1)`,
`[relative by id](/dumps/${dump.id}/edit)`,
`http://gerbeur.test/playlists/${mix.id}?x=1`,
`<http://gerbeur.test/users/bob/~/followed>`,
`[list](/users/BOB/upvoted) [settings](/users/bob/~/settings)`,
`[feed](/~/followed) [search](/search/~/users?q=a)`,
// Another site's, a bare path in prose and a deleted dump stay as they are.
`https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`,
`http://gerbeur.test/dumps/gone-12345678`,
].join("\n");
const expected = [
`[by slug](http://gerbeur.test/@Bob/dumps/never-gonna#comment-1)`,
`[relative by id](/@Bob/dumps/never-gonna/edit)`,
`http://gerbeur.test/@Bob/playlists/sunday-mix?x=1`,
`<http://gerbeur.test/@Bob/follow>`,
`[list](/@Bob/upvoted) [settings](/settings)`,
`[feed](/follow) [search](/search/users?q=a)`,
`https://elsewhere.test/dumps/${dump.v3} see /dumps/${dump.v3}`,
`http://gerbeur.test/dumps/gone-12345678`,
].join("\n");
const commentId = crypto.randomUUID();
const messageId = crypto.randomUUID();
db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`).run(text, dump.id);
db.prepare(`UPDATE playlists SET description = ? WHERE id = ?;`).run(
text,
mix.id,
);
db.prepare(
`INSERT INTO comments (id, dump_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?);`,
).run(commentId, dump.id, bob, text, NOW);
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
).run(messageId, bob, text, NOW);
up0021(db);
const read = (sql: string, id: string) =>
(db.prepare(sql).get(id) as { text: string }).text;
assertEquals(
read(`SELECT comment AS text FROM dumps WHERE id = ?;`, dump.id),
expected,
);
assertEquals(
read(`SELECT body AS text FROM comments WHERE id = ?;`, commentId),
expected,
);
assertEquals(
read(`SELECT body AS text FROM chat_messages WHERE id = ?;`, messageId),
expected,
);
assertEquals(
read(`SELECT description AS text FROM playlists WHERE id = ?;`, mix.id),
expected,
);
db.close();
});

View File

@@ -6,7 +6,9 @@ import { app } from "../main.ts";
import { db } from "../db/connection.ts"; import { db } from "../db/connection.ts";
import { createJWT, createMediaToken } from "../lib/jwt.ts"; import { createJWT, createMediaToken } from "../lib/jwt.ts";
import { hashPassword } from "../lib/password.ts"; import { hashPassword } from "../lib/password.ts";
import { makeSlug } from "../lib/slugify.ts"; import { uniqueSlug } from "../lib/slugify.ts";
import { slugify } from "../../shared/slug.ts";
import { dumpPath, playlistPath } from "../../shared/paths.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../remote/image-proxy.ts"; import { clearImageCache } from "../remote/image-proxy.ts";
@@ -36,6 +38,9 @@ const TABLES = [
"chat_messages", "chat_messages",
"user_settings", "user_settings",
"digest_schedule", "digest_schedule",
"dump_slug_aliases",
"playlist_slug_aliases",
"user_aliases",
"categories", "categories",
"playlists", "playlists",
"dumps", "dumps",
@@ -147,16 +152,24 @@ export interface InsertDumpOptions {
richContent?: Record<string, unknown>; richContent?: Record<string, unknown>;
} }
/** Returns the name of user `userId`, for the page paths of their things. */
function usernameOf(userId: string): string {
return (db.prepare(`SELECT username FROM users WHERE id = ?;`).get(
userId,
) as { username: string }).username;
}
/** /**
* Inserts a dump row directly, skipping the metadata fetch the create route * Inserts a dump row directly, skipping the metadata fetch the create route
* does. Returns its id and slug. * does, with a slug made from its title as the service would. Returns its id,
* slug and page path.
*/ */
export function insertDump( export function insertDump(
opts: InsertDumpOptions, opts: InsertDumpOptions,
): { id: string; slug: string } { ): { id: string; slug: string; path: string } {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const title = opts.title ?? "A dump"; const title = opts.title ?? "A dump";
const slug = makeSlug(title, id); const slug = uniqueSlug(db, "dump", opts.userId, slugify(title));
const kind = opts.kind ?? (opts.fileName ? "file" : "url"); const kind = opts.kind ?? (opts.fileName ? "file" : "url");
const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null; const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null;
const data = opts.fileData ?? new TextEncoder().encode("hello"); const data = opts.fileData ?? new TextEncoder().encode("hello");
@@ -184,15 +197,20 @@ export function insertDump(
Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true }); Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true });
Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data); Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data);
} }
return { id, slug }; return {
id,
slug,
path: dumpPath({ ownerUsername: usernameOf(opts.userId), slug }),
};
} }
/** Inserts a playlist row as {@link insertDump} does a dump. */
export function insertPlaylist( export function insertPlaylist(
opts: { userId: string; title?: string; isPublic?: boolean }, opts: { userId: string; title?: string; isPublic?: boolean },
): { id: string; slug: string } { ): { id: string; slug: string; path: string } {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const title = opts.title ?? "A playlist"; const title = opts.title ?? "A playlist";
const slug = makeSlug(title, id); const slug = uniqueSlug(db, "playlist", opts.userId, slugify(title));
db.prepare( db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, is_public, created_at) `INSERT INTO playlists (id, user_id, title, slug, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?);`,
@@ -204,7 +222,11 @@ export function insertPlaylist(
opts.isPublic === false ? 0 : 1, opts.isPublic === false ? 0 : 1,
new Date().toISOString(), new Date().toISOString(),
); );
return { id, slug }; return {
id,
slug,
path: playlistPath({ ownerUsername: usernameOf(opts.userId), slug }),
};
} }
export function insertComment( export function insertComment(

View File

@@ -12,7 +12,7 @@ test("authenticate: a deleted user's token is refused", async () => {
const user = await createUser(); const user = await createUser();
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id); db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
assertEquals(await authenticate(user.token), null); assertEquals(await authenticate(user.token), null);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals((await get("/api/me", { auth: user.auth })).status, 401);
}); });
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => { test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
@@ -43,7 +43,7 @@ test("authenticateMedia: takes a media token or a legacy session token", async (
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => { test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
const owner = await createUser(); const owner = await createUser();
// A public listing that reads optional auth. // A public listing that reads optional auth.
const path = `/api/users/${owner.username}/dumps`; const path = `/api/users/@${owner.username}/dumps`;
assertEquals((await get(path)).status, 200); assertEquals((await get(path)).status, 200);
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) { for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
assertEquals( assertEquals(

View File

@@ -1,16 +1,13 @@
import "../setup.ts"; import "../setup.ts";
import { assert, assertFalse } from "@std/assert"; import { assert, assertFalse } from "@std/assert";
import { isReservedSlug, isValidSlugFormat } from "../../lib/reserved-slugs.ts"; import { isReservedSlug } from "../../lib/reserved-slugs.ts";
Deno.test("category slugs: format and reserved words", () => { Deno.test("category slugs: the feed tabs, app pages and server paths are reserved", () => {
assert(isValidSlugFormat("music")); for (const word of ["hot", "follow", "settings", "manage", "api", "ws"]) {
assert(isValidSlugFormat("lo-fi-2")); assert(isReservedSlug(word), word);
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { }
assertFalse(isValidSlugFormat(bad), bad); // v3's words are free again: nothing lives at /dumps or /users now.
for (const word of ["music", "dumps", "users", "playlists", "~"]) {
assertFalse(isReservedSlug(word), word);
} }
assert(isReservedSlug("dumps"));
assert(isReservedSlug("api"));
assert(isReservedSlug("ws"));
assert(isReservedSlug("emojibase"));
assertFalse(isReservedSlug("music"));
}); });

View File

@@ -4,10 +4,12 @@ import { isSelfUrl } from "../../lib/self-url.ts";
Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => { Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => {
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite. // GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); assert(isSelfUrl(new URL("http://gerbeur.test/@bob/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob"))); assert(isSelfUrl(new URL("https://GERBEUR.test/@bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1"))); assert(isSelfUrl(new URL("http://gerbeur.test/@bob/playlists")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit"))); assert(isSelfUrl(new URL("http://gerbeur.test/@bob/playlists/p-1")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/@bob/dumps/abc/edit")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/search"))); assertFalse(isSelfUrl(new URL("http://gerbeur.test/search")));
assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob"))); assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
assertFalse(isSelfUrl(new URL("http://elsewhere.test/@bob")));
}); });

View File

@@ -1,19 +1,31 @@
import "../setup.ts"; import "../setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert"; import { assertEquals, assertThrows } from "@std/assert";
import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts"; import { DatabaseSync } from "node:sqlite";
import { uniqueSlug, validateSlug } from "../../lib/slugify.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { Deno.test("uniqueSlug: numbers a clash within the owner's space, aliases included", () => {
assertEquals(slugify("Hello, World!"), "hello-world"); const db = new DatabaseSync(":memory:");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); db.exec(`
assertEquals(slugify(" --already--dashed-- "), "already-dashed"); CREATE TABLE dumps (id TEXT, user_id TEXT, slug TEXT);
assertEquals(slugify("日本語"), "untitled"); CREATE TABLE dump_slug_aliases (user_id TEXT, slug TEXT, dump_id TEXT);
assertEquals(slugify(""), "untitled"); INSERT INTO dumps VALUES ('1', 'bob', 'mix'), ('2', 'bob', 'mix-2');
assertEquals(slugify("x".repeat(100)).length, 60); INSERT INTO dump_slug_aliases VALUES ('bob', 'mix-3', '1');
`);
assertEquals(uniqueSlug(db, "dump", "bob", "mix"), "mix-4");
assertEquals(uniqueSlug(db, "dump", "ann", "mix"), "mix");
// The suffix still fits in 60 characters, cut at no hyphen.
db.prepare(`INSERT INTO dumps VALUES ('3', 'bob', ?);`).run("a".repeat(60));
assertEquals(
uniqueSlug(db, "dump", "bob", "a".repeat(60)),
"a".repeat(58) + "-2",
);
db.close();
}); });
Deno.test("makeSlug: suffixes the first 8 characters of the id", () => { Deno.test("validateSlug: lowercase words joined by hyphens, 60 at most", () => {
const id = "0123abcd-0000-4000-8000-000000000000"; validateSlug("never-gonna-2");
assertEquals(makeSlug("My Song", id), "my-song-0123abcd"); for (const bad of ["Never", "-a", "a-", "a--b", "a b", "", "a".repeat(61)]) {
assert(UUID_RE.test(id)); assertThrows(() => validateSlug(bad), APIException, undefined, bad);
assertFalse(UUID_RE.test("my-song-0123abcd")); }
}); });

View File

@@ -14,7 +14,7 @@ const dump = (title: string, over = {}) => ({
url: "https://g.test/dumps/x", url: "https://g.test/dumps/x",
poster: "bob", poster: "bob",
domain: "example.com", domain: "example.com",
thumbnail: "https://g.test/api/thumbnails/x?v=1", thumbnail: "https://g.test/api/dumps/x/thumbnail?v=1",
votes: 1, votes: 1,
comments: 0, comments: 0,
mine: false, mine: false,

View File

@@ -2,15 +2,15 @@ import { createUser, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
test("required auth: missing, malformed and forged tokens are 401", async () => { test("required auth: missing, malformed and forged tokens are 401", async () => {
assertEquals((await get("/api/users/me")).status, 401); assertEquals((await get("/api/me")).status, 401);
assertEquals( assertEquals(
(await get("/api/users/me", { headers: { Authorization: "Token x" } })) (await get("/api/me", { headers: { Authorization: "Token x" } }))
.status, .status,
401, 401,
); );
const media = (await createUser()).mediaToken; const media = (await createUser()).mediaToken;
assertEquals( assertEquals(
(await get("/api/users/me", { (await get("/api/me", {
headers: { Authorization: `Bearer ${media}` }, headers: { Authorization: `Bearer ${media}` },
})).status, })).status,
401, 401,

View File

@@ -1,6 +1,7 @@
import { import {
BASE_URL, BASE_URL,
createUser, createUser,
db,
insertDump, insertDump,
insertPlaylist, insertPlaylist,
test, test,
@@ -40,7 +41,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => {
thumbnailUrl: "https://img.test/a.jpg", thumbnailUrl: "https://img.test/a.jpg",
}, },
}); });
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`); const { res, text } = await handle(ogApp(), dump.path);
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8"); assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
assertStringIncludes( assertStringIncludes(
text, text,
@@ -61,7 +62,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => {
assertStringIncludes(text, `content="summary_large_image"`); assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes( assertStringIncludes(
text, text,
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`, `<meta property="og:url" content="${BASE_URL}${dump.path}" />`,
); );
// The index's own title is replaced, not duplicated. // The index's own title is replaced, not duplicated.
assertEquals(text.match(/<title>/g)?.length, 1); assertEquals(text.match(/<title>/g)?.length, 1);
@@ -71,7 +72,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => {
test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => { test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => {
const user = await createUser(); const user = await createUser();
const dump = insertDump({ userId: user.id, title: "Plain" }); const dump = insertDump({ userId: user.id, title: "Plain" });
const { text } = await handle(ogApp(), `/dumps/${dump.id}`); const { text } = await handle(ogApp(), dump.path);
assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`); assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`);
assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`); assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`);
const file = insertDump({ const file = insertDump({
@@ -80,8 +81,8 @@ test("ogMiddleware: no artwork falls back to the site icon as a small card", asy
fileMime: "image/png", fileMime: "image/png",
}); });
assertStringIncludes( assertStringIncludes(
(await handle(ogApp(), `/dumps/${file.id}`)).text, (await handle(ogApp(), file.path)).text,
`content="${BASE_URL}/api/files/${file.id}"`, `content="${BASE_URL}/api/dumps/${file.id}/file"`,
); );
}); });
@@ -89,12 +90,16 @@ test("ogMiddleware: users and public playlists; private things fall through", as
const user = await createUser(); const user = await createUser();
const app = ogApp(); const app = ogApp();
assertStringIncludes( assertStringIncludes(
(await handle(app, `/users/${user.username}/~/votes`)).text, (await handle(app, `/@${user.username}/upvoted`)).text,
`<meta property="og:title" content="${user.username}" />`, `<meta property="og:title" content="${user.username}" />`,
); );
assertEquals(
(await handle(app, `/@${user.username}/nope`)).text,
"fallthrough",
);
const pub = insertPlaylist({ userId: user.id, title: "Open list" }); const pub = insertPlaylist({ userId: user.id, title: "Open list" });
assertStringIncludes( assertStringIncludes(
(await handle(app, `/playlists/${pub.slug}`)).text, (await handle(app, pub.path)).text,
"Open list", "Open list",
); );
const priv = insertPlaylist({ const priv = insertPlaylist({
@@ -103,7 +108,7 @@ test("ogMiddleware: users and public playlists; private things fall through", as
isPublic: false, isPublic: false,
}); });
assertEquals( assertEquals(
(await handle(app, `/playlists/${priv.slug}`)).text, (await handle(app, priv.path)).text,
"fallthrough", "fallthrough",
); );
const secret = insertDump({ const secret = insertDump({
@@ -111,11 +116,48 @@ test("ogMiddleware: users and public playlists; private things fall through", as
title: "Secret", title: "Secret",
isPrivate: true, isPrivate: true,
}); });
assertEquals((await handle(app, secret.path)).text, "fallthrough");
assertEquals(
(await handle(app, `/@${user.username}/dumps/nope`)).text,
"fallthrough",
);
// v3 paths aren't redirected.
assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough"); assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough");
assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough");
assertEquals( assertEquals(
(await handle(app, `/api/dumps/${secret.id}`)).text, (await handle(app, `/api/dumps/${secret.id}`)).text,
"fallthrough", "fallthrough",
); );
assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough"); assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough");
}); });
test("ogMiddleware: a former slug, a former name or another case gets a 301", async () => {
const user = await createUser();
const dump = insertDump({ userId: user.id, title: "Now" });
db.prepare(
`INSERT INTO dump_slug_aliases (user_id, slug, dump_id, created_at) VALUES (?, 'before', ?, ?);`,
).run(user.id, dump.id, new Date().toISOString());
db.prepare(
`INSERT INTO user_aliases (username, user_id, created_at) VALUES ('old_name', ?, ?);`,
).run(user.id, new Date().toISOString());
for (
const [from, to] of [
[`/@${user.username}/dumps/before?x=1`, `${dump.path}?x=1`],
[`/@OLD_NAME/dumps/now`, dump.path],
[`/@old_name/follow/playlists`, `/@${user.username}/follow/playlists`],
[`/@${user.username.toUpperCase()}`, `/@${user.username}`],
]
) {
const { res } = await handle(ogApp(), from);
assertEquals(res.status, 301, from);
assertEquals(res.headers.get("location"), `${BASE_URL}${to}`, from);
}
// A private dump's former slug tells nothing.
const secret = insertDump({ userId: user.id, title: "S", isPrivate: true });
db.prepare(
`INSERT INTO dump_slug_aliases (user_id, slug, dump_id, created_at) VALUES (?, 'hush', ?, ?);`,
).run(user.id, secret.id, new Date().toISOString());
assertEquals(
(await handle(ogApp(), `/@${user.username}/dumps/hush`)).text,
"fallthrough",
);
});

View File

@@ -19,6 +19,7 @@ Deno.test("dump rows round-trip through the API shape", () => {
file_size: null, file_size: null,
vote_count: 3, vote_count: 3,
comment_count: 2, comment_count: 2,
owner_username: "bob",
is_private: 1, is_private: 1,
custom_thumbnail_mime: null, custom_thumbnail_mime: null,
}; };

View File

@@ -20,14 +20,14 @@ test("self provider: our own dump, user and playlist pages come from the DB", as
throw new Error("no network for self links"); throw new Error("no network for self links");
}); });
const content = await fetchRichContent( const content = await fetchRichContent(
`http://gerbeur.test/dumps/${dump.slug}`, `http://gerbeur.test${dump.path}`,
); );
assertEquals(content?.title, "Local dump"); assertEquals(content?.title, "Local dump");
assertEquals(content?.description, "About it"); assertEquals(content?.description, "About it");
assertEquals(content?.thumbnailUrl, "https://t/x.jpg"); assertEquals(content?.thumbnailUrl, "https://t/x.jpg");
assertEquals(content?.siteName, "gerbeur"); assertEquals(content?.siteName, "gerbeur");
const profile = await fetchRichContent( const profile = await fetchRichContent(
`http://gerbeur.test/users/${user.username}`, `http://gerbeur.test/@${user.username}/playlists`,
); );
assertEquals(profile?.title, user.username); assertEquals(profile?.title, user.username);
assertEquals(net.calls.length, 0); assertEquals(net.calls.length, 0);
@@ -44,7 +44,7 @@ test("self provider: a private playlist gets no preview", async () => {
throw new Error("no network for self links"); throw new Error("no network for self links");
}); });
const content = await fetchRichContent( const content = await fetchRichContent(
`http://gerbeur.test/playlists/${playlist.id}`, `http://gerbeur.test${playlist.path}`,
); );
assertEquals(content?.title, undefined); assertEquals(content?.title, undefined);
}); });

View File

@@ -1,23 +1,26 @@
import { createUser, formWithFile, get, PNG, post, test } from "../helpers.ts"; import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts";
import type { SelfUser } from "../../../shared/user.ts"; import type { SelfUser } from "../../../shared/user.ts";
import { assertEquals, assertFalse } from "@std/assert"; import { assertEquals, assertFalse } from "@std/assert";
test("avatars: upload, serve, and 404 when there is none", async () => { test("avatars: upload, serve, and 404 when there is none", async () => {
const user = await createUser(); const user = await createUser();
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404); assertEquals((await get(`/api/users/${user.id}/avatar`)).status, 404);
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404); assertEquals(
const res = await post<SelfUser>("/api/avatars/me", { (await get(`/api/users/${crypto.randomUUID()}/avatar`)).status,
404,
);
const res = await put<SelfUser>("/api/me/avatar", {
auth: user.auth, auth: user.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.data.avatarMime, "image/png"); assertEquals(res.data.avatarMime, "image/png");
assertFalse("passwordHash" in res.data); assertFalse("passwordHash" in res.data);
const served = await get(`/api/avatars/${user.id}`); const served = await get(`/api/users/${user.id}/avatar`);
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);
assertEquals( assertEquals(
(await post("/api/avatars/me", { (await put("/api/me/avatar", {
auth: user.auth, auth: user.auth,
form: formWithFile(new Uint8Array([1, 2, 3])), form: formWithFile(new Uint8Array([1, 2, 3])),
})).status, })).status,

View File

@@ -1,89 +0,0 @@
import { get, html, stubNetwork } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import type { Tralbum } from "../../../shared/player.ts";
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x";
const ALBUM = {
item_type: "album",
artist: "Artist",
art_id: 12345,
current: { title: 'The "Album" &amp; more' },
trackinfo: [
{
track_num: 1,
title: "One",
duration: 180.5,
streaming: 1,
file: { "mp3-128": STREAM },
},
{
track_num: 2,
title: "Preorder",
streaming: 1,
unreleased_track: true,
file: null,
},
{
track_num: 3,
title: "",
streaming: 1,
file: { "mp3-128": "https://evil.test/x.mp3" },
},
{
track_num: 4,
title: "Capped",
streaming: 1,
is_capped: true,
file: { "mp3-128": STREAM },
},
],
};
Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => {
using net = stubNetwork(() => html(tralbumPage(ALBUM)));
const url = "https://cached.bandcamp.com/album/x";
const res = await get<Tralbum>(
`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`,
);
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
assertEquals(res.data.tracks.length, 4);
// Same page (different query/case/trailing slash) → cache hit.
await get(
`/api/bandcamp/tracks?url=${
encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y")
}`,
);
assertEquals(net.calls.length, 1);
await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}&force=1`);
assertEquals(net.calls.length, 2);
});
Deno.test("GET /api/bandcamp/tracks: refusals and upstream failures", async () => {
using _ = stubNetwork((url) => {
if (url.hostname.startsWith("down")) {
return new Response("", { status: 503 });
}
if (url.hostname.startsWith("json")) return Response.json({});
return html("<html>no tralbum</html>");
});
const status = async (url: string) =>
(await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`)).status;
assertEquals(await status("https://example.com/album/x"), 400);
assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400);
assertEquals(await status("http://localhost/album/x"), 400);
assertEquals(await status("https://down.bandcamp.com/album/x"), 502);
assertEquals(await status("https://json.bandcamp.com/album/x"), 502);
assertEquals(await status("https://empty.bandcamp.com/album/x"), 404);
});

View File

@@ -14,7 +14,7 @@ import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import { assertEquals, assertStringIncludes } from "@std/assert"; import { assertEquals, assertStringIncludes } from "@std/assert";
test("categories: listed by position, then slug; found by slug", async () => { test("categories: listed by position, then slug", async () => {
insertCategory({ slug: "zeta", position: 0 }); insertCategory({ slug: "zeta", position: 0 });
insertCategory({ slug: "alpha", position: 1 }); insertCategory({ slug: "alpha", position: 1 });
insertCategory({ slug: "beta", position: 0 }); insertCategory({ slug: "beta", position: 0 });
@@ -24,11 +24,6 @@ test("categories: listed by position, then slug; found by slug", async () => {
"zeta", "zeta",
"alpha", "alpha",
]); ]);
assertEquals(
(await get<Category>("/api/categories/alpha")).data.slug,
"alpha",
);
assertEquals((await get("/api/categories/nope")).status, 404);
}); });
test("categories: only category:manage may create, update or delete", async () => { test("categories: only category:manage may create, update or delete", async () => {
@@ -74,7 +69,8 @@ test("categories: slug and name rules, and friendly conflicts", async () => {
for ( for (
const [json, message] of [ const [json, message] of [
[{ slug: "Bad Slug", name: "x" }, "lowercase"], [{ slug: "Bad Slug", name: "x" }, "lowercase"],
[{ slug: "users", name: "x" }, "reserved"], [{ slug: "hot", name: "x" }, "reserved"],
[{ slug: "settings", name: "x" }, "reserved"],
[{ slug: "a".repeat(51), name: "x" }, "at most 50"], [{ slug: "a".repeat(51), name: "x" }, "at most 50"],
[{ slug: "ok", name: " " }, "Name is required"], [{ slug: "ok", name: " " }, "Name is required"],
[{ slug: "ok", name: "x".repeat(51) }, "at most 50"], [{ slug: "ok", name: "x".repeat(51) }, "at most 50"],
@@ -110,7 +106,7 @@ test("categories: slug and name rules, and friendly conflicts", async () => {
assertEquals(ok.data.position, 3); assertEquals(ok.data.position, 3);
}); });
test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => { test("GET /api/dumps?category=: members only, private for the owner", async () => {
const owner = await createUser(); const owner = await createUser();
const cat = insertCategory({ slug: "music" }); const cat = insertCategory({ slug: "music" });
const inCat = insertDump({ userId: owner.id, title: "in" }); const inCat = insertDump({ userId: owner.id, title: "in" });
@@ -126,19 +122,19 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
) )
.run(d.id, cat); .run(d.id, cat);
} }
const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps"); const anon = await get<PaginatedData<Dump>>("/api/dumps?category=music");
assertEquals(anon.data.items.map((d) => d.title), [ assertEquals(anon.data.items.map((d) => d.title), [
"in", "in",
]); ]);
assertEquals(anon.data.items[0].categoryIds, [cat]); assertEquals(anon.data.items[0].categoryIds, [cat]);
assertEquals( assertEquals(
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", { (await get<PaginatedData<Dump>>("/api/dumps?category=music", {
auth: owner.auth, auth: owner.auth,
})).data })).data
.items.length, .items.length,
2, 2,
); );
assertEquals((await get("/api/categories/nope/dumps")).status, 404); assertEquals((await get("/api/dumps?category=nope")).status, 404);
// Deleting the category unlinks its dumps without deleting them. // Deleting the category unlinks its dumps without deleting them.
const admin = await createUser({ role: "admin" }); const admin = await createUser({ role: "admin" });

View File

@@ -21,13 +21,7 @@ import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts"; import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
import { import { assertEquals, assertExists, assertFalse } from "@std/assert";
assertEquals,
assertExists,
assertFalse,
assertMatch,
assertNotEquals,
} from "@std/assert";
const PAGE = html(`<title>Scraped title</title> const PAGE = html(`<title>Scraped title</title>
<meta property="og:image" content="https://example.com/cover.jpg">`); <meta property="og:image" content="https://example.com/cover.jpg">`);
@@ -53,8 +47,9 @@ test("POST /api/dumps: a URL dump takes its title from the page", async () => {
const dump = res.data; const dump = res.data;
assertEquals(dump.kind, "url"); assertEquals(dump.kind, "url");
assertEquals(dump.title, "Scraped title"); assertEquals(dump.title, "Scraped title");
assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/); assertEquals(dump.slug, "scraped-title");
assertEquals(dump.userId, user.id); assertEquals(dump.userId, user.id);
assertEquals(dump.ownerUsername, user.username);
assertEquals(dump.isPrivate, false); assertEquals(dump.isPrivate, false);
assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg"); assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg");
const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`)
@@ -149,7 +144,7 @@ test("POST /api/dumps: a file dump is stored and served back", async () => {
assertEquals(dump.fileSize, PNG.byteLength); assertEquals(dump.fileSize, PNG.byteLength);
assertEquals(dump.comment, "tiny"); assertEquals(dump.comment, "tiny");
assertEquals(readFile(`dumps/${dump.id}`), PNG); assertEquals(readFile(`dumps/${dump.id}`), PNG);
const file = await get(`/api/files/${dump.id}`); const file = await get(`/api/dumps/${dump.id}/file`);
assertEquals(file.status, 200); assertEquals(file.status, 200);
assertEquals(file.bytes, PNG); assertEquals(file.bytes, PNG);
}); });
@@ -221,11 +216,12 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
// ── Read ───────────────────────────────────────────────────────────────────── // ── Read ─────────────────────────────────────────────────────────────────────
test("GET /api/dumps/:id works by id and by slug", async () => { test("GET /api/dumps/:id works by id only", async () => {
const user = await createUser(); const user = await createUser();
const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
assertEquals((await get<Dump>(`/api/dumps/${slug}`)).data.id, id); // A slug is only unique under its owner: it is read by page path.
assertEquals((await get(`/api/dumps/${slug}`)).status, 404);
assertEquals((await get(`/api/dumps/nope`)).status, 404); assertEquals((await get(`/api/dumps/nope`)).status, 404);
assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404);
}); });
@@ -293,7 +289,7 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only",
); );
}); });
test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => { test("GET /api/dumps?url=: matches on the canonical key, respects privacy", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
insertDump({ insertDump({
@@ -308,24 +304,24 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
isPrivate: true, isPrivate: true,
}); });
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`); const anon = await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`);
assertEquals(anon.data.map((d) => d.title), [ assertEquals(anon.data.map((d) => d.title), [
"public", "public",
]); ]);
assertEquals(anon.data[0].username, owner.username); assertEquals(anon.data[0].ownerUsername, owner.username);
const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, { const mine = await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: owner.auth, auth: owner.auth,
}); });
assertEquals(mine.data.length, 2); assertEquals(mine.data.length, 2);
assertEquals( assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, { (await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: other.auth, auth: other.auth,
})).data })).data
.length, .length,
1, 1,
); );
assertEquals( assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=garbage`)).data, (await get<DumpUrlMatch[]>(`/api/dumps?url=garbage`)).data,
[], [],
); );
}); });
@@ -365,7 +361,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
json: { json: {
url: "https://example.com/src", url: "https://example.com/src",
comment: comment:
`see https://www.x.test/t/?utm_source=feed and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, `see https://www.x.test/t/?utm_source=feed and http://anywhere${secret.path}#comment-x and http://gerbeur.test/@${other.username}/dumps/scraped-title`,
}, },
}); });
const srcId = src.data.id; const srcId = src.data.id;
@@ -416,6 +412,48 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
// ── Update ─────────────────────────────────────────────────────────────────── // ── Update ───────────────────────────────────────────────────────────────────
test("PUT /api/dumps/:id: a slug changes when sent, the old one stays an alias, 3 a day", async () => {
const owner = await createUser();
const mod = await createUser({ role: "moderator" });
const { id } = insertDump({ userId: owner.id, title: "First" });
const other = insertDump({ userId: owner.id, title: "Taken" });
const byPath = (slug: string) =>
get<Dump>(`/api/users/@${owner.username}/dumps/${slug}`, {
auth: owner.auth,
});
const setSlug = (slug: string, auth = owner.auth) =>
put<Dump>(`/api/dumps/${id}`, { auth, json: { slug } });
assertEquals((await setSlug("Not A Slug")).status, 400);
// Another dump's slug, current or former, is refused.
assertEquals((await setSlug(other.slug)).status, 409);
assertEquals((await setSlug("second")).data.slug, "second");
// The old slug answers the dump, which carries its current one.
assertEquals((await byPath("first")).data.slug, "second");
assertEquals((await byPath("second")).data.id, id);
// Taking back its own former slug swaps them; the limit counts the aliases
// left, so a swap doesn't add to it.
assertEquals((await setSlug("first")).data.slug, "first");
assertEquals((await byPath("second")).data.slug, "first");
assertEquals((await setSlug("third")).data.slug, "third");
assertEquals((await setSlug("fourth")).data.slug, "fourth");
// A fourth alias in a day is refused, but not to a moderator.
assertEquals((await setSlug("fifth")).status, 429);
assertEquals((await setSlug("fifth", mod.auth)).data.slug, "fifth");
// Every former slug still leads to it, without chaining.
for (const slug of ["first", "second", "third", "fourth", "fifth"]) {
assertEquals((await byPath(slug)).data.slug, "fifth", slug);
}
// Deleting the dump frees its aliases.
await del(`/api/dumps/${id}`, { auth: owner.auth });
assertEquals(
count(`SELECT COUNT(*) AS n FROM dump_slug_aliases WHERE dump_id = ?;`, id),
0,
);
const reborn = insertDump({ userId: owner.id, title: "Second" });
assertEquals(reborn.slug, "second");
});
test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => { test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
@@ -430,14 +468,14 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async (
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
401, 401,
); );
const res = await put<Dump>(`/api/dumps/${slug}`, { const res = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { title: "New title", comment: "hello" }, json: { title: "New title", comment: "hello" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.data.title, "New title"); assertEquals(res.data.title, "New title");
assertMatch(res.data.slug ?? "", /^new-title-/); // A new title never changes the slug by itself.
assertNotEquals(res.data.slug, slug); assertEquals(res.data.slug, slug);
assertExists(res.data.updatedAt); assertExists(res.data.updatedAt);
const byMod = await put(`/api/dumps/${id}`, { const byMod = await put(`/api/dumps/${id}`, {
auth: mod.auth, auth: mod.auth,
@@ -635,7 +673,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
let res = await replace({ isPrivate: "true" }); let res = await replace({ isPrivate: "true" });
assertEquals(res.data.isPrivate, true); assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false); assertEquals(await visible(), false);
assertEquals((await get(`/api/files/${id}`)).status, 404); assertEquals((await get(`/api/dumps/${id}/file`)).status, 404);
// Left out, it stays private. // Left out, it stays private.
res = await replace({}); res = await replace({});
assertEquals(res.data.isPrivate, true); assertEquals(res.data.isPrivate, true);
@@ -677,7 +715,7 @@ test("custom thumbnails: set, served, removed", async () => {
}); });
assertEquals(set.status, 200); assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png"); assertEquals(set.data.thumbnailMime, "image/png");
const thumb = await get(`/api/thumbnails/${id}`); const thumb = await get(`/api/dumps/${id}/thumbnail`);
assertEquals(thumb.status, 200); assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png"); assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG); assertEquals(thumb.bytes, PNG);
@@ -688,10 +726,10 @@ test("custom thumbnails: set, served, removed", async () => {
assertEquals(removed.data.thumbnailMime, undefined); assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null); assertEquals(readFile(`thumbnails/${id}-custom`), null);
// A URL dump without its own thumbnail has none to serve. // A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404);
}); });
test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => { test("POST /api/dumps/:id/metadata: updates, or keeps what it had on failure", async () => {
const owner = await createUser(); const owner = await createUser();
const { id } = insertDump({ const { id } = insertDump({
userId: owner.id, userId: owner.id,
@@ -707,7 +745,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
using _ = stubNetwork(() => { using _ = stubNetwork(() => {
throw new TypeError("down"); throw new TypeError("down");
}); });
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, { const res = await post<Dump>(`/api/dumps/${id}/metadata`, {
auth: owner.auth, auth: owner.auth,
}); });
assertEquals(res.status, 502); assertEquals(res.status, 502);
@@ -715,7 +753,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
} }
{ {
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, { const res = await post<Dump>(`/api/dumps/${id}/metadata`, {
auth: owner.auth, auth: owner.auth,
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
@@ -723,7 +761,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
} }
const file = insertDump({ userId: owner.id, fileName: "f.txt" }); const file = insertDump({ userId: owner.id, fileName: "f.txt" });
assertEquals( assertEquals(
(await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth })) (await post(`/api/dumps/${file.id}/metadata`, { auth: owner.auth }))
.status, .status,
400, 400,
); );

View File

@@ -9,7 +9,7 @@ function createdAtOf(id: string): string {
}).created_at; }).created_at;
} }
test("GET /api/files/:id: headers for a safe inline type", async () => { test("GET /api/dumps/:id/file: headers for a safe inline type", async () => {
const user = await createUser(); const user = await createUser();
const { id } = insertDump({ const { id } = insertDump({
userId: user.id, userId: user.id,
@@ -17,7 +17,7 @@ test("GET /api/files/:id: headers for a safe inline type", async () => {
fileMime: "text/plain", fileMime: "text/plain",
fileData: BYTES, fileData: BYTES,
}); });
const res = await get(`/api/files/${id}`); const res = await get(`/api/dumps/${id}/file`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.bytes, BYTES); assertEquals(res.bytes, BYTES);
assertEquals(res.headers.get("content-type"), "text/plain"); assertEquals(res.headers.get("content-type"), "text/plain");
@@ -33,7 +33,7 @@ test("GET /api/files/:id: headers for a safe inline type", async () => {
assertStringIncludes(etag, 'W/"'); assertStringIncludes(etag, 'W/"');
// The client already has it: a 304, no bytes. // The client already has it: a 304, no bytes.
const again = await get(`/api/files/${id}`, { const again = await get(`/api/dumps/${id}/file`, {
headers: { "If-None-Match": etag }, headers: { "If-None-Match": etag },
}); });
assertEquals(again.status, 304); assertEquals(again.status, 304);
@@ -42,17 +42,17 @@ test("GET /api/files/:id: headers for a safe inline type", async () => {
// The app's URL names the file's current version, which never changes. // The app's URL names the file's current version, which never changes.
const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`; const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`;
assertEquals( assertEquals(
(await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"), (await get(`/api/dumps/${id}/file?v=${v}`)).headers.get("cache-control"),
"public, max-age=31536000, immutable", "public, max-age=31536000, immutable",
); );
// A stale version is revalidated like no version at all. // A stale version is revalidated like no version at all.
assertEquals( assertEquals(
(await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"), (await get(`/api/dumps/${id}/file?v=10-0`)).headers.get("cache-control"),
"public, no-cache", "public, no-cache",
); );
}); });
test("GET /api/files/:id: active content downloads instead of rendering", async () => { test("GET /api/dumps/:id/file: active content downloads instead of rendering", async () => {
const user = await createUser(); const user = await createUser();
for (const mime of ["text/html", "image/svg+xml", "application/xml"]) { for (const mime of ["text/html", "image/svg+xml", "application/xml"]) {
const { id } = insertDump({ const { id } = insertDump({
@@ -60,7 +60,7 @@ test("GET /api/files/:id: active content downloads instead of rendering", async
fileName: "x", fileName: "x",
fileMime: mime, fileMime: mime,
}); });
const res = await get(`/api/files/${id}`); const res = await get(`/api/dumps/${id}/file`);
assertStringIncludes( assertStringIncludes(
res.headers.get("content-disposition")!, res.headers.get("content-disposition")!,
"attachment;", "attachment;",
@@ -72,19 +72,19 @@ test("GET /api/files/:id: active content downloads instead of rendering", async
fileName: "a.pdf", fileName: "a.pdf",
fileMime: "application/pdf", fileMime: "application/pdf",
}); });
const res = await get(`/api/files/${pdf.id}`); const res = await get(`/api/dumps/${pdf.id}/file`);
assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); assertStringIncludes(res.headers.get("content-disposition")!, "inline;");
assertEquals(res.headers.get("content-security-policy"), null); assertEquals(res.headers.get("content-security-policy"), null);
}); });
test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => { test("GET /api/dumps/:id/file: a non-Latin-1 filename doesn't break the header", async () => {
const user = await createUser(); const user = await createUser();
const { id } = insertDump({ const { id } = insertDump({
userId: user.id, userId: user.id,
fileName: 'mix "été" 🎵.mp3', fileName: 'mix "été" 🎵.mp3',
fileMime: "audio/mpeg", fileMime: "audio/mpeg",
}); });
const res = await get(`/api/files/${id}`); const res = await get(`/api/dumps/${id}/file`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals( assertEquals(
res.headers.get("content-disposition"), res.headers.get("content-disposition"),
@@ -94,7 +94,7 @@ test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", asyn
); );
}); });
test("GET /api/files/:id: byte ranges", async () => { test("GET /api/dumps/:id/file: byte ranges", async () => {
const user = await createUser(); const user = await createUser();
const { id } = insertDump({ const { id } = insertDump({
userId: user.id, userId: user.id,
@@ -102,7 +102,7 @@ test("GET /api/files/:id: byte ranges", async () => {
fileData: BYTES, fileData: BYTES,
}); });
const range = (r: string) => const range = (r: string) =>
get(`/api/files/${id}`, { headers: { Range: r } }); get(`/api/dumps/${id}/file`, { headers: { Range: r } });
let res = await range("bytes=2-5"); let res = await range("bytes=2-5");
assertEquals(res.status, 206); assertEquals(res.status, 206);
@@ -156,7 +156,7 @@ test("GET /api/files/:id: byte ranges", async () => {
} }
}); });
test("GET /api/files/:id: private files need the owner's media token", async () => { test("GET /api/dumps/:id/file: private files need the owner's media token", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
const { id } = insertDump({ const { id } = insertDump({
@@ -164,28 +164,31 @@ test("GET /api/files/:id: private files need the owner's media token", async ()
fileName: "p", fileName: "p",
isPrivate: true, isPrivate: true,
}); });
assertEquals((await get(`/api/files/${id}`)).status, 404); assertEquals((await get(`/api/dumps/${id}/file`)).status, 404);
assertEquals( assertEquals(
(await get(`/api/files/${id}?token=${other.mediaToken}`)).status, (await get(`/api/dumps/${id}/file?token=${other.mediaToken}`)).status,
404, 404,
); );
assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404); assertEquals((await get(`/api/dumps/${id}/file?token=garbage`)).status, 404);
const res = await get(`/api/files/${id}?token=${owner.mediaToken}`); const res = await get(`/api/dumps/${id}/file?token=${owner.mediaToken}`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, no-cache"); assertEquals(res.headers.get("cache-control"), "private, no-cache");
// A legacy session token still works in the query string. // A legacy session token still works in the query string.
assertEquals( assertEquals(
(await get(`/api/files/${id}?token=${owner.token}`)).status, (await get(`/api/dumps/${id}/file?token=${owner.token}`)).status,
200, 200,
); );
}); });
test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => { test("GET /api/dumps/:id/file: bad ids, URL dumps and missing bytes", async () => {
const user = await createUser(); const user = await createUser();
assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400); assertEquals(
(await get(`/api/dumps/..%2F..%2Fetc%2Fpasswd/file`)).status,
400,
);
const url = insertDump({ userId: user.id }); const url = insertDump({ userId: user.id });
assertEquals((await get(`/api/files/${url.id}`)).status, 404); assertEquals((await get(`/api/dumps/${url.id}/file`)).status, 404);
const gone = insertDump({ userId: user.id, fileName: "g" }); const gone = insertDump({ userId: user.id, fileName: "g" });
Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`); Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`);
assertEquals((await get(`/api/files/${gone.id}`)).status, 404); assertEquals((await get(`/api/dumps/${gone.id}/file`)).status, 404);
}); });

View File

@@ -9,6 +9,7 @@ import {
insertDump, insertDump,
insertPlaylist, insertPlaylist,
post, post,
put,
test, test,
} from "../helpers.ts"; } from "../helpers.ts";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
@@ -26,35 +27,35 @@ test("following users: status, idempotence, no self-follow, one notification", a
const me = await createUser(); const me = await createUser();
const them = await createUser(); const them = await createUser();
assertEquals( assertEquals(
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, (await put(`/api/me/follow/users/${them.id}`, { auth: me.auth })).status,
204, 204,
); );
assertEquals( assertEquals(
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, (await put(`/api/me/follow/users/${them.id}`, { auth: me.auth })).status,
204, 204,
); );
assertEquals( assertEquals(
(await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status, (await put(`/api/me/follow/users/${me.id}`, { auth: me.auth })).status,
400, 400,
); );
assertEquals( assertEquals(
(await post(`/api/follows/users/${crypto.randomUUID()}`, { (await put(`/api/me/follow/users/${crypto.randomUUID()}`, {
auth: me.auth, auth: me.auth,
})).status, })).status,
404, 404,
); );
assertEquals( assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data, (await get<FollowStatus>("/api/me/follow", { auth: me.auth })).data,
{ followedUserIds: [them.id], followedPlaylistIds: [] }, { followedUserIds: [them.id], followedPlaylistIds: [] },
); );
// Unfollow and follow again: still just the one notification. // Unfollow and follow again: still just the one notification.
await del(`/api/follows/users/${them.id}`, { auth: me.auth }); await del(`/api/me/follow/users/${them.id}`, { auth: me.auth });
assertEquals( assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data (await get<FollowStatus>("/api/me/follow", { auth: me.auth })).data
.followedUserIds, .followedUserIds,
[], [],
); );
await post(`/api/follows/users/${them.id}`, { auth: me.auth }); await put(`/api/me/follow/users/${them.id}`, { auth: me.auth });
assertEquals( assertEquals(
count( count(
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`, `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`,
@@ -63,7 +64,7 @@ test("following users: status, idempotence, no self-follow, one notification", a
1, 1,
); );
const list = await get<PaginatedData<PublicUser>>( const list = await get<PaginatedData<PublicUser>>(
`/api/users/${me.username}/followed-users`, `/api/users/@${me.username}/follow/users`,
); );
assertEquals(list.data.items.map((u) => u.id), [ assertEquals(list.data.items.map((u) => u.id), [
them.id, them.id,
@@ -77,20 +78,21 @@ test("following playlists: public ones only, and not your own notification", asy
const pub = insertPlaylist({ userId: owner.id }); const pub = insertPlaylist({ userId: owner.id });
const priv = insertPlaylist({ userId: owner.id, isPublic: false }); const priv = insertPlaylist({ userId: owner.id, isPublic: false });
assertEquals( assertEquals(
(await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status, (await put(`/api/me/follow/playlists/${pub.id}`, { auth: me.auth })).status,
204, 204,
); );
assertEquals( assertEquals(
(await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, (await put(`/api/me/follow/playlists/${priv.id}`, { auth: me.auth }))
.status,
404, 404,
); );
assertEquals( assertEquals(
(await post(`/api/follows/playlists/${crypto.randomUUID()}`, { (await put(`/api/me/follow/playlists/${crypto.randomUUID()}`, {
auth: me.auth, auth: me.auth,
})).status, })).status,
404, 404,
); );
await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth }); await put(`/api/me/follow/playlists/${pub.id}`, { auth: owner.auth });
assertEquals( assertEquals(
count( count(
`SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`, `SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`,
@@ -99,14 +101,14 @@ test("following playlists: public ones only, and not your own notification", asy
); );
assertEquals( assertEquals(
(await get<PaginatedData<Playlist>>( (await get<PaginatedData<Playlist>>(
`/api/users/${me.username}/followed-playlists`, `/api/users/@${me.username}/follow/playlists`,
)).data )).data
.items[0].id, .items[0].id,
pub.id, pub.id,
); );
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); await del(`/api/me/follow/playlists/${pub.id}`, { auth: me.auth });
assertEquals( assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data (await get<FollowStatus>("/api/me/follow", { auth: me.auth })).data
.followedPlaylistIds, .followedPlaylistIds,
[], [],
); );
@@ -133,27 +135,27 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
.run(tagged.id, music); .run(tagged.id, music);
insertDump({ userId: followed.id, title: "private", isPrivate: true }); insertDump({ userId: followed.id, title: "private", isPrivate: true });
insertDump({ userId: stranger.id, title: "stranger" }); insertDump({ userId: stranger.id, title: "stranger" });
await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); await put(`/api/me/follow/users/${followed.id}`, { auth: me.auth });
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/users", { const feed = await get<PaginatedData<Dump>>("/api/me/feed?from=users", {
auth: me.auth, auth: me.auth,
}); });
assertEquals(titles(feed), ["new", "old"]); assertEquals(titles(feed), ["new", "old"]);
assertEquals(feed.data.items[0].categoryIds, [music]); assertEquals(feed.data.items[0].categoryIds, [music]);
assertEquals( assertEquals(
titles( titles(
await get("/api/follows/feed/users?category=music", { auth: me.auth }), await get("/api/me/feed?from=users&category=music", { auth: me.auth }),
), ),
["new"], ["new"],
); );
// An unknown category filters everything out rather than nothing. // An unknown category filters everything out rather than nothing.
assertEquals( assertEquals(
(await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", { (await get<PaginatedData<Dump>>("/api/me/feed?from=users&category=nope", {
auth: me.auth, auth: me.auth,
})).data, })).data,
{ items: [], hasMore: false }, { items: [], hasMore: false },
); );
assertEquals((await get("/api/follows/feed/users")).status, 401); assertEquals((await get("/api/me/feed?from=users")).status, 401);
}); });
test("followed-playlists feed: each dump once, public playlists only", async () => { test("followed-playlists feed: each dump once, public playlists only", async () => {
@@ -168,11 +170,11 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
isPrivate: true, isPrivate: true,
}); });
for (const p of [p1, p2]) { for (const p of [p1, p2]) {
await post(`/api/follows/playlists/${p.id}`, { auth: me.auth }); await put(`/api/me/follow/playlists/${p.id}`, { auth: me.auth });
await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth });
} }
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/playlists", { const feed = await get<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
auth: me.auth, auth: me.auth,
}); });
assertEquals(titles(feed), ["shared"]); assertEquals(titles(feed), ["shared"]);
@@ -181,7 +183,7 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
// A playlist gone private drops out of the feed. // A playlist gone private drops out of the feed.
db.prepare(`UPDATE playlists SET is_public = 0;`).run(); db.prepare(`UPDATE playlists SET is_public = 0;`).run();
assertEquals( assertEquals(
(await get<PaginatedData<Dump>>("/api/follows/feed/playlists", { (await get<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
auth: me.auth, auth: me.auth,
})).data })).data
.items.length, .items.length,

View File

@@ -1,5 +1,18 @@
import { count, createUser, get, insertDump, post, test } from "../helpers.ts"; import {
import type { Notification } from "../../../shared/notification.ts"; count,
createUser,
db,
get,
insertDump,
post,
put,
test,
} from "../helpers.ts";
import type {
DumpCommentedData,
DumpUpvotedData,
Notification,
} from "../../../shared/notification.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
@@ -8,14 +21,14 @@ test("notifications: listed newest first, marked read one or all, never someone
const other = await createUser(); const other = await createUser();
const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" }); const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" });
// Two notifications for me, one for other. // Two notifications for me, one for other.
await post(`/api/follows/users/${me.id}`, { auth: other.auth }); await put(`/api/me/follow/users/${me.id}`, { auth: other.auth });
await post(`/api/dumps/${dumpId}/comments`, { await post(`/api/dumps/${dumpId}/comments`, {
auth: other.auth, auth: other.auth,
json: { body: "hey" }, json: { body: "hey" },
}); });
await post(`/api/follows/users/${other.id}`, { auth: me.auth }); await put(`/api/me/follow/users/${other.id}`, { auth: me.auth });
const list = await get<PaginatedData<Notification>>("/api/notifications", { const list = await get<PaginatedData<Notification>>("/api/me/notifications", {
auth: me.auth, auth: me.auth,
}); });
assertEquals(list.data.items.length, 2); assertEquals(list.data.items.length, 2);
@@ -29,7 +42,7 @@ test("notifications: listed newest first, marked read one or all, never someone
); );
assertEquals( assertEquals(
(await post("/api/notifications/read-all", { auth: me.auth })).status, (await post("/api/me/notifications/read", { auth: me.auth })).status,
204, 204,
); );
assertEquals( assertEquals(
@@ -39,5 +52,49 @@ test("notifications: listed newest first, marked read one or all, never someone
), ),
0, 0,
); );
assertEquals((await get("/api/notifications")).status, 401); assertEquals((await get("/api/me/notifications")).status, 401);
});
test("notifications: names and item paths are joined when read, so they follow renames", async () => {
const me = await createUser();
const fan = await createUser();
const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" });
await post(`/api/dumps/${dumpId}/comments`, {
auth: fan.auth,
json: { body: "nice" },
});
db.prepare(`UPDATE users SET username = 'fan_renamed' WHERE id = ?;`)
.run(fan.id);
db.prepare(`UPDATE dumps SET slug = 'mine-now' WHERE id = ?;`).run(dumpId);
const [note] = (await get<PaginatedData<Notification>>(
"/api/me/notifications",
{ auth: me.auth },
)).data.items;
assertEquals(note.type, "dump_commented");
const data = note.data as DumpCommentedData;
assertEquals(data.commenterUsername, "fan_renamed");
assertEquals([data.dumpOwnerUsername, data.dumpSlug], [
me.username,
"mine-now",
]);
// Once the dump is gone, the notification has no path to it.
db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
db.prepare(
`INSERT INTO notifications (id, user_id, type, data, created_at) VALUES (?, ?, 'dump_upvoted', ?, ?);`,
).run(
crypto.randomUUID(),
me.id,
JSON.stringify({
voterId: fan.id,
voterUsername: "x",
dumpId,
dumpTitle: "Mine",
}),
new Date().toISOString(),
);
const [upvote] = (await get<PaginatedData<Notification>>(
"/api/me/notifications",
{ auth: me.auth },
)).data.items;
assertEquals((upvote.data as DumpUpvotedData).dumpSlug, undefined);
}); });

View File

@@ -26,7 +26,7 @@ function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
return res.data.dumps.map((d) => d.id); return res.data.dumps.map((d) => d.id);
} }
test("POST /api/playlists creates; GET finds it by id or slug", async () => { test("POST /api/playlists creates; GET finds it by id, or by path under its owner", async () => {
const user = await createUser(); const user = await createUser();
const res = await post<Playlist>("/api/playlists", { const res = await post<Playlist>("/api/playlists", {
auth: user.auth, auth: user.auth,
@@ -34,12 +34,16 @@ test("POST /api/playlists creates; GET finds it by id or slug", async () => {
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const { id, slug } = res.data; const { id, slug } = res.data;
assertMatch(slug ?? "", /^road-trip-/); assertEquals(slug, "road-trip");
assertEquals(res.data.ownerUsername, user.username);
assertEquals( assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title, (await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
"Road Trip", "Road Trip",
); );
const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`); assertEquals((await get(`/api/playlists/${slug}`)).status, 404);
const bySlug = await get<PlaylistWithDumps>(
`/api/users/@${user.username}/playlists/${slug}`,
);
assertEquals(bySlug.data.id, id); assertEquals(bySlug.data.id, id);
assertEquals(bySlug.data.ownerUsername, user.username); assertEquals(bySlug.data.ownerUsername, user.username);
assertEquals(bySlug.data.dumps, []); assertEquals(bySlug.data.dumps, []);
@@ -69,7 +73,7 @@ test("private playlists are the owner's alone", async () => {
200, 200,
); );
insertPlaylist({ userId: owner.id }); insertPlaylist({ userId: owner.id });
const path = `/api/users/${owner.username}/playlists`; const path = `/api/users/@${owner.username}/playlists`;
assertEquals((await get<PaginatedData<Playlist>>(path)).data.items.length, 1); assertEquals((await get<PaginatedData<Playlist>>(path)).data.items.length, 1);
assertEquals( assertEquals(
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.items (await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.items
@@ -220,8 +224,28 @@ test("removing a dump; updating; deleting", async () => {
json: { title: "New", description: "d", isPublic: false }, json: { title: "New", description: "d", isPublic: false },
}); });
assertEquals(updated.data.title, "New"); assertEquals(updated.data.title, "New");
assertMatch(updated.data.slug ?? "", /^new-/); // A new title never changes the slug by itself; a slug sent does.
assertEquals(updated.data.slug, "old");
assertEquals(updated.data.isPublic, false); assertEquals(updated.data.isPublic, false);
const reslugged = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth,
json: { slug: "new" },
});
assertEquals(reslugged.data.slug, "new");
assertEquals(
(await get<PlaylistWithDumps>(
`/api/users/@${owner.username}/playlists/old`,
{ auth: owner.auth },
)).data.slug,
"new",
);
assertEquals(
(await put(`/api/playlists/${id}`, {
auth: owner.auth,
json: { slug: "Bad slug" },
})).status,
400,
);
const cleared = await put<Playlist>(`/api/playlists/${id}`, { const cleared = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { description: null }, json: { description: null },
@@ -253,7 +277,7 @@ test("memberships: which of my playlists hold this dump", async () => {
const d = insertDump({ userId: owner.id }); const d = insertDump({ userId: owner.id });
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth }); await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
const res = await get<PlaylistMembership[]>( const res = await get<PlaylistMembership[]>(
`/api/playlists/by-dump/${d.id}/memberships`, `/api/me/playlists?contains=${d.id}`,
{ {
auth: owner.auth, auth: owner.auth,
}, },
@@ -273,7 +297,7 @@ test("adding to a followed public playlist notifies followers, private dumps exc
const owner = await createUser(); const owner = await createUser();
const follower = await createUser(); const follower = await createUser();
const { id } = insertPlaylist({ userId: owner.id, title: "Mix" }); const { id } = insertPlaylist({ userId: owner.id, title: "Mix" });
await post(`/api/follows/playlists/${id}`, { auth: follower.auth }); await put(`/api/me/follow/playlists/${id}`, { auth: follower.auth });
db.exec(`DELETE FROM notifications;`); // the follow notified the owner db.exec(`DELETE FROM notifications;`); // the follow notified the owner
const priv = insertDump({ userId: owner.id, isPrivate: true }); const priv = insertDump({ userId: owner.id, isPrivate: true });
await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth }); await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth });
@@ -296,20 +320,20 @@ test("adding to a followed public playlist notifies followers, private dumps exc
}); });
}); });
test("playlist images: upload and serve", async () => { test("playlist covers: upload and serve", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
const { id, slug } = insertPlaylist({ userId: owner.id }); const { id } = insertPlaylist({ userId: owner.id });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404);
assertEquals( assertEquals(
(await post(`/api/playlists/${id}/image`, { (await put(`/api/playlists/${id}/cover`, {
auth: other.auth, auth: other.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
})) }))
.status, .status,
403, 403,
); );
const res = await post<Playlist>(`/api/playlists/${slug}/image`, { const res = await put<Playlist>(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
@@ -317,37 +341,37 @@ test("playlist images: upload and serve", async () => {
assertEquals(res.data.imageMime, "image/png"); assertEquals(res.data.imageMime, "image/png");
// A new cover counts as an edit, so image URLs versioned on it change. // A new cover counts as an edit, so image URLs versioned on it change.
assert(res.data.updatedAt); assert(res.data.updatedAt);
const served = await get(`/api/playlists/${slug}/image`); const served = await get(`/api/playlists/${id}/cover`);
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);
}); });
test("playlist images: a private playlist's cover only for its owner's media token", async () => { test("playlist covers: a private playlist's cover only for its owner's media token", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
const { id } = insertPlaylist({ userId: owner.id, isPublic: false }); const { id } = insertPlaylist({ userId: owner.id, isPublic: false });
await post(`/api/playlists/${id}/image`, { await put(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404);
assertEquals( assertEquals(
(await get(`/api/playlists/${id}/image?token=${other.mediaToken}`)).status, (await get(`/api/playlists/${id}/cover?token=${other.mediaToken}`)).status,
404, 404,
); );
assertEquals( assertEquals(
(await get(`/api/playlists/${id}/image?token=garbage`)).status, (await get(`/api/playlists/${id}/cover?token=garbage`)).status,
404, 404,
); );
const served = await get( const served = await get(
`/api/playlists/${id}/image?token=${owner.mediaToken}`, `/api/playlists/${id}/cover?token=${owner.mediaToken}`,
); );
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);
assertMatch(served.headers.get("cache-control") ?? "", /^private/); assertMatch(served.headers.get("cache-control") ?? "", /^private/);
}); });
test("playlist images: a failed write leaves the row and the old cover alone", async () => { test("playlist covers: a failed write leaves the row and the old cover alone", async () => {
const owner = await createUser(); const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id }); const { id } = insertPlaylist({ userId: owner.id });
const dir = `${TEST_DIR}/uploads/playlist-images`; const dir = `${TEST_DIR}/uploads/playlist-images`;
@@ -359,27 +383,27 @@ test("playlist images: a failed write leaves the row and the old cover alone", a
// the playlist mustn't claim a cover it hasn't got. // the playlist mustn't claim a cover it hasn't got.
await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true }); await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true });
try { try {
const failed = await post(`/api/playlists/${id}/image`, { const failed = await put(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(failed.status, 500); assertEquals(failed.status, 500);
assertEquals(row(), { image_mime: null, updated_at: null }); assertEquals(row(), { image_mime: null, updated_at: null });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404);
} finally { } finally {
await Deno.remove(`${dir}/${id}`, { recursive: true }); await Deno.remove(`${dir}/${id}`, { recursive: true });
} }
// With a cover in place, a failed replacement keeps serving the old one // With a cover in place, a failed replacement keeps serving the old one
// under the old version, and leaves no stray temporary file behind. // under the old version, and leaves no stray temporary file behind.
await post(`/api/playlists/${id}/image`, { await put(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
const before = row(); const before = row();
await Deno.chmod(dir, 0o555); await Deno.chmod(dir, 0o555);
try { try {
const failed = await post(`/api/playlists/${id}/image`, { const failed = await put(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(new Uint8Array([...PNG, 0])), form: formWithFile(new Uint8Array([...PNG, 0])),
}); });
@@ -388,28 +412,28 @@ test("playlist images: a failed write leaves the row and the old cover alone", a
await Deno.chmod(dir, 0o755); await Deno.chmod(dir, 0o755);
} }
assertEquals(row(), before); assertEquals(row(), before);
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, PNG); assertEquals((await get(`/api/playlists/${id}/cover`)).bytes, PNG);
const left = [...Deno.readDirSync(dir)] const left = [...Deno.readDirSync(dir)]
.map((e) => e.name) .map((e) => e.name)
.filter((name) => name.startsWith(id)); .filter((name) => name.startsWith(id));
assertEquals(left, [id]); assertEquals(left, [id]);
}); });
test("playlist images: a replaced cover is served under the new version", async () => { test("playlist covers: a replaced cover is served under the new version", async () => {
const owner = await createUser(); const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id }); const { id } = insertPlaylist({ userId: owner.id });
const first = await post<Playlist>(`/api/playlists/${id}/image`, { const first = await put<Playlist>(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
const other = new Uint8Array([...PNG, 1, 2, 3]); const other = new Uint8Array([...PNG, 1, 2, 3]);
const second = await post<Playlist>(`/api/playlists/${id}/image`, { const second = await put<Playlist>(`/api/playlists/${id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(other), form: formWithFile(other),
}); });
assert(second.data.updatedAt !== first.data.updatedAt); assert(second.data.updatedAt !== first.data.updatedAt);
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other); assertEquals((await get(`/api/playlists/${id}/cover`)).bytes, other);
}); });
test("reorder: someone else's dump that went private keeps its slot", async () => { test("reorder: someone else's dump that went private keeps its slot", async () => {

View File

@@ -1,46 +0,0 @@
import { get, PNG, stubNetwork } from "../helpers.ts";
import { assertEquals } from "@std/assert";
Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async () => {
using net = stubNetwork((url) =>
url.pathname.endsWith(".svg")
? new Response("<svg/>", { headers: { "content-type": "image/svg+xml" } })
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.png")
}`;
const first = await get(path);
assertEquals(first.status, 200);
assertEquals(first.headers.get("content-type"), "image/png");
assertEquals(first.bytes, PNG);
const second = await get(path);
assertEquals(second.bytes, PNG);
assertEquals(net.calls.length, 1);
const svg = await get(
`/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.svg")
}`,
);
assertEquals(svg.status, 400);
});
Deno.test("GET /api/proxy-image: an error answer is never served nor cached", async () => {
let failing = true;
using net = stubNetwork(() =>
failing
? new Response(PNG, {
status: 503,
headers: { "content-type": "image/png" },
})
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/flaky.png")
}`;
assertEquals((await get(path)).status, 400);
failing = false;
assertEquals((await get(path)).status, 200);
assertEquals(net.calls.length, 2);
});

View File

@@ -0,0 +1,199 @@
import { get, html, PNG, stubNetwork } from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts";
// ── Images ───────────────────────────────────────────────────────────────────
Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", async () => {
using net = stubNetwork((url) =>
url.pathname.endsWith(".svg")
? new Response("<svg/>", { headers: { "content-type": "image/svg+xml" } })
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/a.png")
}`;
const first = await get(path);
assertEquals(first.status, 200);
assertEquals(first.headers.get("content-type"), "image/png");
assertEquals(first.bytes, PNG);
const second = await get(path);
assertEquals(second.bytes, PNG);
assertEquals(net.calls.length, 1);
const svg = await get(
`/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/a.svg")
}`,
);
assertEquals(svg.status, 400);
});
Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => {
let failing = true;
using net = stubNetwork(() =>
failing
? new Response(PNG, {
status: 503,
headers: { "content-type": "image/png" },
})
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/flaky.png")
}`;
assertEquals((await get(path)).status, 400);
failing = false;
assertEquals((await get(path)).status, 200);
assertEquals(net.calls.length, 2);
});
// ── Bandcamp ─────────────────────────────────────────────────────────────────
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x";
const ALBUM = {
item_type: "album",
artist: "Artist",
art_id: 12345,
current: { title: 'The "Album" &amp; more' },
trackinfo: [
{
track_num: 1,
title: "One",
duration: 180.5,
streaming: 1,
file: { "mp3-128": STREAM },
},
{
track_num: 2,
title: "Preorder",
streaming: 1,
unreleased_track: true,
file: null,
},
{
track_num: 3,
title: "",
streaming: 1,
file: { "mp3-128": "https://evil.test/x.mp3" },
},
{
track_num: 4,
title: "Capped",
streaming: 1,
is_capped: true,
file: { "mp3-128": STREAM },
},
],
};
Deno.test("GET /api/remote/bandcamp: resolves, caches, and forces a refetch", async () => {
using net = stubNetwork(() => html(tralbumPage(ALBUM)));
const url = "https://cached.bandcamp.com/album/x";
const res = await get<Tralbum>(
`/api/remote/bandcamp?url=${encodeURIComponent(url)}`,
);
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
assertEquals(res.data.tracks.length, 4);
// Same page (different query/case/trailing slash) → cache hit.
await get(
`/api/remote/bandcamp?url=${
encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y")
}`,
);
assertEquals(net.calls.length, 1);
await get(`/api/remote/bandcamp?url=${encodeURIComponent(url)}&force=1`);
assertEquals(net.calls.length, 2);
});
Deno.test("GET /api/remote/bandcamp: refusals and upstream failures", async () => {
using _ = stubNetwork((url) => {
if (url.hostname.startsWith("down")) {
return new Response("", { status: 503 });
}
if (url.hostname.startsWith("json")) return Response.json({});
return html("<html>no tralbum</html>");
});
const status = async (url: string) =>
(await get(`/api/remote/bandcamp?url=${encodeURIComponent(url)}`)).status;
assertEquals(await status("https://example.com/album/x"), 400);
assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400);
assertEquals(await status("http://localhost/album/x"), 400);
assertEquals(await status("https://down.bandcamp.com/album/x"), 502);
assertEquals(await status("https://json.bandcamp.com/album/x"), 502);
assertEquals(await status("https://empty.bandcamp.com/album/x"), 404);
});
// ── YouTube ──────────────────────────────────────────────────────────────────
function ytPage(data: unknown): string {
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
const YT_DATA = {
metadata: { playlistMetadataRenderer: { title: "My mix" } },
contents: {
list: [
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_VIDEO",
contentId: "vid1",
metadata: {
lockupMetadataViewModel: { title: { content: "First" } },
},
contentImage: {
overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }],
},
},
},
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST",
contentId: "ignored",
},
},
{
playlistVideoRenderer: {
videoId: "vid2",
title: { runs: [{ text: "Second" }] },
lengthSeconds: "235",
},
},
],
},
};
Deno.test("GET /api/remote/youtube: resolves and validates the id", async () => {
let cookie: string | null = null;
using net = stubNetwork((url, init) => {
cookie = new Headers(init?.headers).get("cookie");
return url.searchParams.get("list") === "PLgone"
? new Response("", { status: 404 })
: html(ytPage(YT_DATA));
});
const ok = await get<YouTubePlaylist>("/api/remote/youtube?list=PLgood");
assertEquals(ok.status, 200);
assertEquals(ok.data.tracks.length, 2);
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
// The pre-answered consent cookie keeps EU requests off the consent page.
assertEquals(cookie, "SOCS=CAI");
await get("/api/remote/youtube?list=PLgood"); // cached
assertEquals(net.calls.length, 1);
assertEquals((await get("/api/remote/youtube?list=bad%20id")).status, 400);
assertEquals((await get("/api/remote/youtube")).status, 400);
assertEquals((await get("/api/remote/youtube?list=PLgone")).status, 502);
assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id"));
});

View File

@@ -37,7 +37,7 @@ function readDir(path: string): string[] {
} }
} }
test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => { test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy", async () => {
const owner = await createUser(); const owner = await createUser();
const video = insertDump({ const video = insertDump({
userId: owner.id, userId: owner.id,
@@ -54,20 +54,21 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async
for (const { id } of [video, priv]) { for (const { id } of [video, priv]) {
Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG); Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG);
} }
const res = await get(`/api/thumbnails/${video.id}`); const res = await get(`/api/dumps/${video.id}/thumbnail`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/jpeg"); assertEquals(res.headers.get("content-type"), "image/jpeg");
assertEquals(res.headers.get("cache-control"), "public, no-cache"); assertEquals(res.headers.get("cache-control"), "public, no-cache");
const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`; const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`;
assertEquals( assertEquals(
(await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).headers.get( (await get(`/api/dumps/${video.id}/thumbnail?v=${v(video.id)}`)).headers
"cache-control", .get(
), "cache-control",
),
"public, max-age=31536000, immutable", "public, max-age=31536000, immutable",
); );
assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); assertEquals((await get(`/api/dumps/${priv.id}/thumbnail`)).status, 404);
const own = await get( const own = await get(
`/api/thumbnails/${priv.id}?v=${v(priv.id)}&token=${owner.mediaToken}`, `/api/dumps/${priv.id}/thumbnail?v=${v(priv.id)}&token=${owner.mediaToken}`,
); );
assertEquals(own.status, 200); assertEquals(own.status, 200);
assertEquals( assertEquals(
@@ -76,11 +77,11 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async
); );
const text = insertDump({ userId: owner.id, fileName: "t.txt" }); const text = insertDump({ userId: owner.id, fileName: "t.txt" });
assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404); assertEquals((await get(`/api/dumps/${text.id}/thumbnail`)).status, 404);
assertEquals((await get(`/api/thumbnails/nope`)).status, 400); assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
}); });
test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => { test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => {
const owner = await createUser(); const owner = await createUser();
const gif = new TextEncoder().encode("GIF89a-not-really"); const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({ const animated = insertDump({
@@ -89,7 +90,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn
fileMime: "image/gif", fileMime: "image/gif",
fileData: gif, fileData: gif,
}); });
const res = await get(`/api/thumbnails/${animated.id}`); const res = await get(`/api/dumps/${animated.id}/thumbnail`);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif"); assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif); assertEquals(res.bytes, gif);
@@ -101,7 +102,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn
fileMime: "image/png", fileMime: "image/png",
fileData: PNG, fileData: PNG,
}); });
const preview = await get(`/api/thumbnails/${photo.id}`); const preview = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(preview.status, 200); assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png"); assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) { if (await hasFfmpeg()) {
@@ -120,7 +121,7 @@ test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", asyn
fileMime: "image/png", fileMime: "image/png",
fileData: BYTES, fileData: BYTES,
}); });
const fallback = await get(`/api/thumbnails/${broken.id}`); const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200); assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png"); assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES); assertEquals(fallback.bytes, BYTES);

View File

@@ -6,10 +6,13 @@ import {
del, del,
get, get,
insertDump, insertDump,
insertPlaylist,
PASSWORD, PASSWORD,
patch, patch,
post, post,
put,
test, test,
uniqueName,
} from "../helpers.ts"; } from "../helpers.ts";
import type { import type {
AuthResponse, AuthResponse,
@@ -28,6 +31,7 @@ import {
} from "../../lib/jwt.ts"; } from "../../lib/jwt.ts";
import { authenticateMedia } from "../../lib/auth.ts"; import { authenticateMedia } from "../../lib/auth.ts";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -42,10 +46,14 @@ function invite(inviterId: string, createdAt = new Date()): string {
return token; return token;
} }
test("public profile: by username or id, without private fields", async () => { test("public profile: by @name in any case or id, without private fields", async () => {
const user = await createUser(); const user = await createUser();
for ( for (
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`] const path of [
`/api/users/@${user.username}`,
`/api/users/@${user.username.toUpperCase()}`,
`/api/users/${user.id}`,
]
) { ) {
const res = await get<PublicUser>(path); const res = await get<PublicUser>(path);
assertEquals(res.status, 200, path); assertEquals(res.status, 200, path);
@@ -53,53 +61,55 @@ test("public profile: by username or id, without private fields", async () => {
assertFalse("email" in res.data); assertFalse("email" in res.data);
assertFalse("passwordHash" in res.data); assertFalse("passwordHash" in res.data);
} }
assertEquals((await get(`/api/users/no_one_here`)).status, 404); assertEquals((await get(`/api/users/@no_one_here`)).status, 404);
const me = await get<SelfUser>("/api/users/me", { auth: user.auth }); // Without the `@`, the key is an id.
assertEquals((await get(`/api/users/${user.username}`)).status, 404);
const me = await get<SelfUser>("/api/me", { auth: user.auth });
assertEquals(me.data.email, user.email); assertEquals(me.data.email, user.email);
assertFalse("passwordHash" in me.data); assertFalse("passwordHash" in me.data);
}); });
test("PATCH /api/users/me: description and email only", async () => { test("PATCH /api/me: description, email and username only", async () => {
const user = await createUser(); const user = await createUser();
const taken = await createUser(); const taken = await createUser();
const res = await patch<PublicUser>("/api/users/me", { const res = await patch<SelfUser>("/api/me", {
auth: user.auth, auth: user.auth,
json: { description: "about me", email: "new@example.test" }, json: { description: "about me", email: "new@example.test" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.data.description, "about me"); assertEquals(res.data.description, "about me");
assertFalse("email" in res.data); assertEquals(res.data.email, "new@example.test");
assertEquals( assertFalse("passwordHash" in res.data);
(await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email, // A role or password in the body is ignored, not applied.
"new@example.test", const hash = () =>
); (db.prepare(`SELECT password_hash FROM users WHERE id = ?;`).get(
// A role, username or password in the body is ignored, not applied. user.id,
await patch("/api/users/me", { ) as { password_hash: string }).password_hash;
const before = hash();
await patch("/api/me", {
auth: user.auth, auth: user.auth,
json: { role: "admin", username: "hijack", password: "whatever-long" }, json: { role: "admin", password: "whatever-long" },
}); });
const row = db.prepare(`SELECT role, username FROM users WHERE id = ?;`).get( const row = db.prepare(`SELECT role FROM users WHERE id = ?;`).get(
user.id, user.id,
) as { ) as { role: string };
role: string; assertEquals(row.role, "user");
username: string; assertEquals(hash(), before);
};
assertEquals(row, { role: "user", username: user.username });
assertEquals( assertEquals(
(await patch("/api/users/me", { (await patch("/api/me", {
auth: user.auth, auth: user.auth,
json: { email: taken.email }, json: { email: taken.email },
})).status, })).status,
409, 409,
); );
assertEquals( assertEquals(
(await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } })) (await patch("/api/me", { auth: user.auth, json: { email: "bad" } }))
.status, .status,
400, 400,
); );
assertEquals( assertEquals(
(await patch("/api/users/me", { (await patch("/api/me", {
auth: user.auth, auth: user.auth,
json: { description: "x".repeat(2001) }, json: { description: "x".repeat(2001) },
})).status, })).status,
@@ -107,7 +117,7 @@ test("PATCH /api/users/me: description and email only", async () => {
); );
}); });
test("PATCH /api/users/:id/role: admins only, never their own", async () => { test("PATCH /api/users/:key/role: admins only, never their own", async () => {
const admin = await createUser({ role: "admin" }); const admin = await createUser({ role: "admin" });
const mod = await createUser({ role: "moderator" }); const mod = await createUser({ role: "moderator" });
const user = await createUser(); const user = await createUser();
@@ -157,28 +167,28 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => {
); );
}); });
test("GET /api/users/search: prefix match for mentions, capped at 8", async () => { test("GET /api/users?q=: prefix match for mentions, capped at 8", async () => {
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` }); for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
await createUser({ username: "other_zed" }); await createUser({ username: "other_zed" });
const res = await get<UserSuggestion[]>("/api/users/search?q=zed"); const res = await get<UserSuggestion[]>("/api/users?q=zed");
assertEquals(res.data.length, 8); assertEquals(res.data.length, 8);
assertEquals(res.data[0], { assertEquals(res.data[0], {
id: res.data[0].id, id: res.data[0].id,
username: "zed_0", username: "zed_0",
avatarMime: null, avatarMime: null,
}); });
assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []); assertEquals((await get<UserSuggestion[]>("/api/users?q=")).data, []);
// An underscore is a letter of the name, not a wildcard. // An underscore is a letter of the name, not a wildcard.
await createUser({ username: "zedx9" }); await createUser({ username: "zedx9" });
assertEquals( assertEquals(
(await get<UserSuggestion[]>("/api/users/search?q=zed_9")).data.map((u) => (await get<UserSuggestion[]>("/api/users?q=zed_9")).data.map((u) =>
u.username u.username
), ),
["zed_9"], ["zed_9"],
); );
}); });
test("GET /api/users/:username/invitees: the invite tree below a user", async () => { test("GET /api/users/:key/invitees: the invite tree below a user", async () => {
const root = await createUser(); const root = await createUser();
const child = await createUser({ const child = await createUser({
invitedBy: root.id, invitedBy: root.id,
@@ -189,18 +199,18 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async ()
createdAt: "2024-01-02T00:00:00Z", createdAt: "2024-01-02T00:00:00Z",
}); });
const tree = const tree =
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data; (await get<Invitee[]>(`/api/users/@${root.username}/invitees`)).data;
assertEquals( assertEquals(
tree.map(( tree.map((
n: { username: string; invitedById: string }, n: { username: string; invitedById: string },
) => [n.username, n.invitedById]), ) => [n.username, n.invitedById]),
[[child.username, root.id], [grandchild.username, child.id]], [[child.username, root.id], [grandchild.username, child.id]],
); );
const profile = await get<PublicUser>(`/api/users/${child.username}`); const profile = await get<PublicUser>(`/api/users/@${child.username}`);
assertEquals(profile.data.invitedByUsername, root.username); assertEquals(profile.data.invitedByUsername, root.username);
}); });
test("GET /api/users/:username/dumps and /votes respect privacy", async () => { test("GET /api/users/:key/dumps and /upvoted respect privacy", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
const pub = insertDump({ userId: owner.id, title: "pub" }); const pub = insertDump({ userId: owner.id, title: "pub" });
@@ -211,8 +221,8 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
) )
.run(d.id, owner.id, new Date().toISOString()); .run(d.id, owner.id, new Date().toISOString());
} }
for (const sub of ["dumps", "votes"]) { for (const sub of ["dumps", "upvoted"]) {
const path = `/api/users/${owner.username}/${sub}`; const path = `/api/users/@${owner.username}/${sub}`;
const total = async (opts?: { auth: { Authorization: string } }) => const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.items.length; (await get<PaginatedData<Dump>>(path, opts)).data.items.length;
assertEquals(await total(), 1, sub); assertEquals(await total(), 1, sub);
@@ -223,14 +233,14 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
sub, sub,
); );
} }
assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404); assert((await get(`/api/users/@nobody_at_all/dumps`)).status === 404);
}); });
// ── Login and register ─────────────────────────────────────────────────────── // ── Login and register ───────────────────────────────────────────────────────
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { test("POST /api/sessions: right password logs in, wrong one doesn't say which", async () => {
const user = await createUser(); const user = await createUser();
const ok = await post<AuthResponse>("/api/users/login", { const ok = await post<AuthResponse>("/api/sessions", {
json: { username: user.username, password: PASSWORD }, json: { username: user.username, password: PASSWORD },
}); });
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
@@ -242,10 +252,10 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which
user.id, user.id,
); );
const wrong = await post("/api/users/login", { const wrong = await post("/api/sessions", {
json: { username: user.username, password: "nope" }, json: { username: user.username, password: "nope" },
}); });
const unknown = await post("/api/users/login", { const unknown = await post("/api/sessions", {
json: { username: "no_such_user_here", password: "nope" }, json: { username: "no_such_user_here", password: "nope" },
}); });
assertEquals(wrong.status, 401); assertEquals(wrong.status, 401);
@@ -253,19 +263,19 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which
assertEquals(wrong.error.message, unknown.error.message); assertEquals(wrong.error.message, unknown.error.message);
assertEquals( assertEquals(
(await post("/api/users/login", { json: { username: user.username } })) (await post("/api/sessions", { json: { username: user.username } }))
.status, .status,
400, 400,
); );
}); });
test("POST /api/users/login: rate limited per account, case-insensitively", async () => { test("POST /api/sessions: rate limited per account, case-insensitively", async () => {
const user = await createUser(); const user = await createUser();
const statuses: number[] = []; const statuses: number[] = [];
for (let i = 0; i < 11; i++) { for (let i = 0; i < 11; i++) {
const username = i % 2 ? user.username.toUpperCase() : user.username; const username = i % 2 ? user.username.toUpperCase() : user.username;
statuses.push( statuses.push(
(await post("/api/users/login", { (await post("/api/sessions", {
json: { username, password: "wrong" }, json: { username, password: "wrong" },
})).status, })).status,
); );
@@ -274,18 +284,18 @@ test("POST /api/users/login: rate limited per account, case-insensitively", asyn
assertEquals(statuses[10], 429); assertEquals(statuses[10], 429);
// Even the right password is held back now. // Even the right password is held back now.
assertEquals( assertEquals(
(await post("/api/users/login", { (await post("/api/sessions", {
json: { username: user.username, password: PASSWORD }, json: { username: user.username, password: PASSWORD },
})).status, })).status,
429, 429,
); );
}); });
test("POST /api/users/register: an invite opens one account", async () => { test("POST /api/users: an invite opens one account", async () => {
using mail = captureEmails(); using mail = captureEmails();
const inviter = await createUser(); const inviter = await createUser();
const inviteToken = invite(inviter.id); const inviteToken = invite(inviter.id);
const res = await post<AuthResponse>("/api/users/register", { const res = await post<AuthResponse>("/api/users", {
json: { json: {
username: "newbie_reg", username: "newbie_reg",
password: "longenough", password: "longenough",
@@ -306,7 +316,7 @@ test("POST /api/users/register: an invite opens one account", async () => {
assertEquals(mail.sent.map((m) => m.to), ["newbie@example.test"]); assertEquals(mail.sent.map((m) => m.to), ["newbie@example.test"]);
assert(mail.sent[0].html.includes("newbie_reg")); assert(mail.sent[0].html.includes("newbie_reg"));
const again = await post("/api/users/register", { const again = await post("/api/users", {
json: { json: {
username: "second_reg", username: "second_reg",
password: "longenough", password: "longenough",
@@ -317,7 +327,7 @@ test("POST /api/users/register: an invite opens one account", async () => {
assertEquals(again.status, 409); assertEquals(again.status, 409);
}); });
test("POST /api/users/register: validation and unknown invites", async () => { test("POST /api/users: validation and unknown invites", async () => {
const inviter = await createUser(); const inviter = await createUser();
const inviteToken = invite(inviter.id); const inviteToken = invite(inviter.id);
const base = { const base = {
@@ -335,11 +345,11 @@ test("POST /api/users/register: validation and unknown invites", async () => {
[{ ...base, inviteToken: 42 }, /Invalid request/], [{ ...base, inviteToken: 42 }, /Invalid request/],
]; ];
for (const [json, message] of cases) { for (const [json, message] of cases) {
const res = await post("/api/users/register", { json }); const res = await post("/api/users", { json });
assertEquals(res.status, 400, JSON.stringify(json)); assertEquals(res.status, 400, JSON.stringify(json));
assert(message.test(res.error.message), res.error.message); assert(message.test(res.error.message), res.error.message);
} }
const unknown = await post("/api/users/register", { const unknown = await post("/api/users", {
json: { ...base, inviteToken: "made-up" }, json: { ...base, inviteToken: "made-up" },
}); });
assertEquals(unknown.status, 404); assertEquals(unknown.status, 404);
@@ -350,11 +360,11 @@ test("POST /api/users/register: validation and unknown invites", async () => {
); );
}); });
test("POST /api/users/register: a taken name or email hands the invite back", async () => { test("POST /api/users: a taken name or email hands the invite back", async () => {
const inviter = await createUser(); const inviter = await createUser();
const taken = await createUser(); const taken = await createUser();
const inviteToken = invite(inviter.id); const inviteToken = invite(inviter.id);
const byName = await post("/api/users/register", { const byName = await post("/api/users", {
json: { json: {
username: taken.username.toUpperCase(), username: taken.username.toUpperCase(),
password: "longenough", password: "longenough",
@@ -363,7 +373,7 @@ test("POST /api/users/register: a taken name or email hands the invite back", as
}, },
}); });
assertEquals(byName.status, 409); assertEquals(byName.status, 409);
const byEmail = await post("/api/users/register", { const byEmail = await post("/api/users", {
json: { json: {
username: "fresh_name", username: "fresh_name",
password: "longenough", password: "longenough",
@@ -381,13 +391,13 @@ test("POST /api/users/register: a taken name or email hands the invite back", as
); );
}); });
test("POST /api/users/register: an invite older than 7 days is refused", async () => { test("POST /api/users: an invite older than 7 days is refused", async () => {
const inviter = await createUser(); const inviter = await createUser();
const inviteToken = invite( const inviteToken = invite(
inviter.id, inviter.id,
new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
); );
const res = await post("/api/users/register", { const res = await post("/api/users", {
json: { json: {
username: "late_comer", username: "late_comer",
password: "longenough", password: "longenough",
@@ -398,12 +408,12 @@ test("POST /api/users/register: an invite older than 7 days is refused", async (
assertEquals(res.status, 404); assertEquals(res.status, 404);
}); });
test("POST /api/users/register: parallel registrations can't share an invite", async () => { test("POST /api/users: parallel registrations can't share an invite", async () => {
const inviter = await createUser(); const inviter = await createUser();
const inviteToken = invite(inviter.id); const inviteToken = invite(inviter.id);
const results = await Promise.all( const results = await Promise.all(
[1, 2, 3].map((i) => [1, 2, 3].map((i) =>
post("/api/users/register", { post("/api/users", {
json: { json: {
username: `racer_${i}`, username: `racer_${i}`,
password: "longenough", password: "longenough",
@@ -424,12 +434,12 @@ test("POST /api/users/register: parallel registrations can't share an invite", a
test("change-password: needs the current one, revokes old tokens, returns new ones", async () => { test("change-password: needs the current one, revokes old tokens, returns new ones", async () => {
const user = await createUser(); const user = await createUser();
const wrong = await post("/api/users/me/change-password", { const wrong = await put("/api/me/password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: "nope", newPassword: "brand-new-pass" }, json: { currentPassword: "nope", newPassword: "brand-new-pass" },
}); });
assertEquals(wrong.status, 403); assertEquals(wrong.status, 403);
const short = await post("/api/users/me/change-password", { const short = await put("/api/me/password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "short" }, json: { currentPassword: PASSWORD, newPassword: "short" },
}); });
@@ -438,18 +448,18 @@ test("change-password: needs the current one, revokes old tokens, returns new on
// iat has one-second resolution: make sure the old token is from an earlier // iat has one-second resolution: make sure the old token is from an earlier
// second than the change. // second than the change.
await new Promise((r) => setTimeout(r, 1100)); await new Promise((r) => setTimeout(r, 1100));
const ok = await post<AuthTokens>("/api/users/me/change-password", { const ok = await put<AuthTokens>("/api/me/password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
}); });
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals((await get("/api/me", { auth: user.auth })).status, 401);
assertEquals(await authenticateMedia(user.mediaToken), null); assertEquals(await authenticateMedia(user.mediaToken), null);
const fresh = { Authorization: `Bearer ${ok.data.token}` }; const fresh = { Authorization: `Bearer ${ok.data.token}` };
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); assertEquals((await get("/api/me", { auth: fresh })).status, 200);
assertEquals(await authenticateMedia(ok.data.mediaToken), user.id); assertEquals(await authenticateMedia(ok.data.mediaToken), user.id);
const login = await post("/api/users/login", { const login = await post("/api/sessions", {
json: { username: user.username, password: "brand-new-pass" }, json: { username: user.username, password: "brand-new-pass" },
}); });
assertEquals(login.status, 200); assertEquals(login.status, 200);
@@ -458,7 +468,7 @@ test("change-password: needs the current one, revokes old tokens, returns new on
test("request-password-reset: always 200, stores nothing without SMTP", async () => { test("request-password-reset: always 200, stores nothing without SMTP", async () => {
const user = await createUser(); const user = await createUser();
for (const email of [user.email, "nobody@example.test", "", undefined]) { for (const email of [user.email, "nobody@example.test", "", undefined]) {
const res = await post("/api/users/request-password-reset", { const res = await post("/api/password-resets", {
json: { email }, json: { email },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
@@ -469,10 +479,10 @@ test("request-password-reset: always 200, stores nothing without SMTP", async ()
test("request-password-reset: emails a link that resets the password", async () => { test("request-password-reset: emails a link that resets the password", async () => {
using mail = captureEmails(); using mail = captureEmails();
const user = await createUser(); const user = await createUser();
await post("/api/users/request-password-reset", { await post("/api/password-resets", {
json: { email: user.email }, json: { email: user.email },
}); });
await post("/api/users/request-password-reset", { await post("/api/password-resets", {
json: { email: "nobody@example.test" }, json: { email: "nobody@example.test" },
}); });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -481,8 +491,8 @@ test("request-password-reset: emails a link that resets the password", async ()
const token = decodeURIComponent( const token = decodeURIComponent(
mail.sent[0].text.match(/token=([^\s)]+)/)![1], mail.sent[0].text.match(/token=([^\s)]+)/)![1],
); );
const res = await post("/api/users/reset-password", { const res = await put(`/api/password-resets/${token}`, {
json: { token, newPassword: "from-the-email" }, json: { newPassword: "from-the-email" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
}); });
@@ -494,22 +504,22 @@ test("reset-password: a stored token works once", async () => {
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
const short = await post("/api/users/reset-password", { const short = await put(`/api/password-resets/${token}`, {
json: { token, newPassword: "short" }, json: { newPassword: "short" },
}); });
assertEquals(short.status, 400); assertEquals(short.status, 400);
const ok = await post("/api/users/reset-password", { const ok = await put(`/api/password-resets/${token}`, {
json: { token, newPassword: "reset-password-1" }, json: { newPassword: "reset-password-1" },
}); });
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
const login = await post("/api/users/login", { const login = await post("/api/sessions", {
json: { username: user.username, password: "reset-password-1" }, json: { username: user.username, password: "reset-password-1" },
}); });
assertEquals(login.status, 200); assertEquals(login.status, 200);
const reuse = await post("/api/users/reset-password", { const reuse = await put(`/api/password-resets/${token}`, {
json: { token, newPassword: "reset-password-2" }, json: { newPassword: "reset-password-2" },
}); });
assertEquals(reuse.status, 409); assertEquals(reuse.status, 409);
}); });
@@ -518,28 +528,28 @@ test("reset-password: an unstored, forged or wrong-purpose token fails", async (
const user = await createUser(); const user = await createUser();
const unstored = await createPasswordResetToken(user.id); const unstored = await createPasswordResetToken(user.id);
assertEquals( assertEquals(
(await post("/api/users/reset-password", { (await put(`/api/password-resets/${unstored}`, {
json: { token: unstored, newPassword: "whatever-123" }, json: { newPassword: "whatever-123" },
})).status, })).status,
404, 404,
); );
for (const token of ["garbage", user.token, user.mediaToken]) { for (const token of ["garbage", user.token, user.mediaToken]) {
assertEquals( assertEquals(
(await post("/api/users/reset-password", { (await put(`/api/password-resets/${token}`, {
json: { token, newPassword: "whatever-123" }, json: { newPassword: "whatever-123" },
})).status, })).status,
400, 400,
); );
} }
assertEquals( assertEquals(
(await post("/api/users/reset-password", { json: { token: 1 } })).status, (await put(`/api/password-resets/${unstored}`, { json: {} })).status,
400, 400,
); );
}); });
test("GET /api/users/me/media-token issues a media token for the caller", async () => { test("GET /api/me/media-token issues a media token for the caller", async () => {
const user = await createUser(); const user = await createUser();
const res = await get<MediaTokenResponse>("/api/users/me/media-token", { const res = await get<MediaTokenResponse>("/api/me/media-token", {
auth: user.auth, auth: user.auth,
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
@@ -551,12 +561,12 @@ test("GET /api/users/me/media-token issues a media token for the caller", async
test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => { test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => {
const ann = await createUser(); const ann = await createUser();
const bob = await createUser(); const bob = await createUser();
const initial = await get<UserSettings>("/api/users/me/settings", { const initial = await get<UserSettings>("/api/me/settings", {
auth: ann.auth, auth: ann.auth,
}); });
assertEquals(initial.data, DEFAULT_SETTINGS); assertEquals(initial.data, DEFAULT_SETTINGS);
const changed = await patch<UserSettings>("/api/users/me/settings", { const changed = await patch<UserSettings>("/api/me/settings", {
auth: ann.auth, auth: ann.auth,
json: { style: "kanagawa", digestWeekly: false }, json: { style: "kanagawa", digestWeekly: false },
}); });
@@ -565,7 +575,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their
style: "kanagawa", style: "kanagawa",
digestWeekly: false, digestWeekly: false,
}); });
const scheme = await patch<UserSettings>("/api/users/me/settings", { const scheme = await patch<UserSettings>("/api/me/settings", {
auth: ann.auth, auth: ann.auth,
json: { colorScheme: "dark" }, json: { colorScheme: "dark" },
}); });
@@ -573,7 +583,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their
assertEquals(scheme.data.colorScheme, "dark"); assertEquals(scheme.data.colorScheme, "dark");
assertEquals(scheme.data.digestWeekly, false); assertEquals(scheme.data.digestWeekly, false);
const bobs = await get<UserSettings>("/api/users/me/settings", { const bobs = await get<UserSettings>("/api/me/settings", {
auth: bob.auth, auth: bob.auth,
}); });
assertEquals(bobs.data, DEFAULT_SETTINGS); assertEquals(bobs.data, DEFAULT_SETTINGS);
@@ -584,7 +594,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their
test("settings: signed in only, and a bad value or unknown key is refused", async () => { test("settings: signed in only, and a bad value or unknown key is refused", async () => {
const ann = await createUser(); const ann = await createUser();
assertEquals((await get("/api/users/me/settings")).status, 401); assertEquals((await get("/api/me/settings")).status, 401);
for ( for (
const json of [ const json of [
{ style: "comic" }, { style: "comic" },
@@ -594,13 +604,182 @@ test("settings: signed in only, and a bad value or unknown key is refused", asyn
[], [],
] ]
) { ) {
const res = await patch("/api/users/me/settings", { auth: ann.auth, json }); const res = await patch("/api/me/settings", { auth: ann.auth, json });
assertEquals(res.status, 400, JSON.stringify(json)); assertEquals(res.status, 400, JSON.stringify(json));
} }
// Null hands the style back to the instance's. // Null hands the style back to the instance's.
const reset = await patch<UserSettings>("/api/users/me/settings", { const reset = await patch<UserSettings>("/api/me/settings", {
auth: ann.auth, auth: ann.auth,
json: { style: null }, json: { style: null },
}); });
assertEquals(reset.data.style, null); assertEquals(reset.data.style, null);
}); });
// ── Usernames and paths ──────────────────────────────────────────────────────
test("renaming: the old name redirects for good, and nobody else can take it", async () => {
const bob = await createUser();
const old = bob.username;
const renamed = uniqueName("robert");
const dump = insertDump({ userId: bob.id, title: "Never gonna" });
const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" });
const res = await patch<SelfUser>("/api/me", {
auth: bob.auth,
json: { username: renamed },
});
assertEquals(res.data.username, renamed);
// The old name, in any case, still answers the user, under the new one.
for (const name of [old, old.toUpperCase(), renamed]) {
assertEquals(
(await get<PublicUser>(`/api/users/@${name}`)).data.username,
renamed,
name,
);
}
// Their dumps and playlists follow them to the new name.
for (const name of [old, renamed]) {
const d = await get<Dump>(`/api/users/@${name}/dumps/${dump.slug}`);
assertEquals([d.data.id, d.data.ownerUsername], [dump.id, renamed]);
const p = await get<Playlist>(
`/api/users/@${name}/playlists/${playlist.slug}`,
);
assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]);
}
// Nobody can register or rename to the old name, in any case.
const other = await createUser();
const register = await post("/api/users", {
json: {
username: old.toUpperCase(),
password: PASSWORD,
email: `${uniqueName()}@example.test`,
inviteToken: invite(other.id),
},
});
assertEquals(register.status, 409);
assertEquals(
(await patch("/api/me", { auth: other.auth, json: { username: old } }))
.status,
409,
);
// Nor to a current one.
assertEquals(
(await patch("/api/me", {
auth: other.auth,
json: { username: renamed },
})).status,
409,
);
// Signing in takes the current name only.
assertEquals(
(await post("/api/sessions", {
json: { username: old, password: PASSWORD },
})).status,
401,
);
});
test("renaming: once in 30 days, a case change aside, and a moderator is exempt", async () => {
const user = await createUser();
const first = uniqueName("first");
assertEquals(
(await patch("/api/me", { auth: user.auth, json: { username: first } }))
.status,
200,
);
// A change of case alone leaves no alias, so it isn't counted.
assertEquals(
(await patch<SelfUser>("/api/me", {
auth: user.auth,
json: { username: first.toUpperCase() },
})).data.username,
first.toUpperCase(),
);
const again = await patch("/api/me", {
auth: user.auth,
json: { username: uniqueName("second") },
});
assertEquals(again.status, 429);
assertEquals(
(await patch("/api/me", { auth: user.auth, json: { username: "a b" } }))
.status,
400,
);
// An admin renames anyone at any time, and themselves too.
const admin = await createUser({ role: "admin" });
const third = uniqueName("third");
assertEquals(
(await patch<PublicUser>(`/api/users/${user.id}/username`, {
auth: admin.auth,
json: { username: third },
})).data.username,
third,
);
assertEquals(
(await patch<PublicUser>(`/api/users/@${third}/username`, {
auth: admin.auth,
json: { username: first },
})).data.username,
first,
"a former name of one's own can be taken back",
);
for (let i = 0; i < 2; i++) {
assertEquals(
(await patch("/api/me", {
auth: admin.auth,
json: { username: uniqueName("admin") },
})).status,
200,
);
}
// Others may not.
const moderator = await createUser({ role: "moderator" });
assertEquals(
(await patch(`/api/users/${user.id}/username`, {
auth: moderator.auth,
json: { username: uniqueName() },
})).status,
403,
);
});
test("GET /api/users/:key/dumps/:slug and /playlists/:slug: by path, privacy kept", async () => {
const owner = await createUser();
const other = await createUser();
const dump = insertDump({ userId: owner.id, title: "Hello there" });
const secret = insertDump({
userId: owner.id,
title: "Secret",
isPrivate: true,
});
const hidden = insertPlaylist({
userId: owner.id,
title: "Hidden",
isPublic: false,
});
assertEquals(dump.slug, "hello-there");
assertEquals(
(await get<Dump>(`/api/users/@${owner.username}/dumps/hello-there`)).data
.id,
dump.id,
);
assertEquals(
(await get(`/api/users/${owner.id}/dumps/hello-there`)).status,
200,
);
// Another user's space holds none of it.
assertEquals(
(await get(`/api/users/@${other.username}/dumps/hello-there`)).status,
404,
);
const secretPath = `/api/users/@${owner.username}/dumps/${secret.slug}`;
assertEquals((await get(secretPath)).status, 404);
assertEquals((await get(secretPath, { auth: other.auth })).status, 404);
assertEquals((await get(secretPath, { auth: owner.auth })).status, 200);
const hiddenPath = `/api/users/@${owner.username}/playlists/${hidden.slug}`;
assertEquals((await get(hiddenPath)).status, 404);
assertEquals((await get(hiddenPath, { auth: owner.auth })).status, 200);
});

View File

@@ -578,7 +578,7 @@ wsTest("a password change closes the user's sockets", async (connect) => {
const user = await createUser(); const user = await createUser();
const c = await connect(user); const c = await connect(user);
await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts
const res = await post<AuthTokens>("/api/users/me/change-password", { const res = await put<AuthTokens>("/api/me/password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "a-new-password" }, json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
}); });
@@ -642,14 +642,14 @@ wsTest(
const pl = insertPlaylist({ userId: owner.id }); const pl = insertPlaylist({ userId: owner.id });
const c = await connect(); const c = await connect();
const cover = new Uint8Array([...PNG, 9, 9, 9]); const cover = new Uint8Array([...PNG, 9, 9, 9]);
const upload = post(`/api/playlists/${pl.id}/image`, { const upload = put(`/api/playlists/${pl.id}/cover`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(cover), form: formWithFile(cover),
}); });
// Fetch the image the moment the socket says it changed, as a client would. // Fetch the image the moment the socket says it changed, as a client would.
const msg = await c.next("playlist_updated"); const msg = await c.next("playlist_updated");
const served = await get( const served = await get(
`/api/playlists/${pl.id}/image?v=${msg.playlist.updatedAt}`, `/api/playlists/${pl.id}/cover?v=${msg.playlist.updatedAt}`,
); );
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, cover); assertEquals(served.bytes, cover);

View File

@@ -1,63 +0,0 @@
import { get, html, stubNetwork } from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { YouTubePlaylist } from "../../../shared/player.ts";
function ytPage(data: unknown): string {
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
const YT_DATA = {
metadata: { playlistMetadataRenderer: { title: "My mix" } },
contents: {
list: [
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_VIDEO",
contentId: "vid1",
metadata: {
lockupMetadataViewModel: { title: { content: "First" } },
},
contentImage: {
overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }],
},
},
},
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST",
contentId: "ignored",
},
},
{
playlistVideoRenderer: {
videoId: "vid2",
title: { runs: [{ text: "Second" }] },
lengthSeconds: "235",
},
},
],
},
};
Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => {
let cookie: string | null = null;
using net = stubNetwork((url, init) => {
cookie = new Headers(init?.headers).get("cookie");
return url.searchParams.get("list") === "PLgone"
? new Response("", { status: 404 })
: html(ytPage(YT_DATA));
});
const ok = await get<YouTubePlaylist>("/api/youtube/playlist?list=PLgood");
assertEquals(ok.status, 200);
assertEquals(ok.data.tracks.length, 2);
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
// The pre-answered consent cookie keeps EU requests off the consent page.
assertEquals(cookie, "SOCS=CAI");
await get("/api/youtube/playlist?list=PLgood"); // cached
assertEquals(net.calls.length, 1);
assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400);
assertEquals((await get("/api/youtube/playlist")).status, 400);
assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502);
assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id"));
});

View File

@@ -19,6 +19,7 @@ import {
getSettings, getSettings,
updateSettings, updateSettings,
} from "../../services/settings-service.ts"; } from "../../services/settings-service.ts";
import { createUnsubscribeToken } from "../../lib/jwt.ts";
const DAY = 86_400_000; const DAY = 86_400_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -148,6 +149,77 @@ test("buildDigest: the weekly digest gathers activity, network, top and communit
); );
assertEquals(digest.community?.totals.dumps, 3); assertEquals(digest.community?.totals.dumps, 3);
assert(digest.links.unsubscribe.includes("/unsubscribe?token=")); assert(digest.links.unsubscribe.includes("/unsubscribe?token="));
// Every page link has a v4 shape, and none names anything by its id.
const pageLinks = [
...digest.activity!.highlights.flatMap((h) =>
h.target?.url ? [h.target.url] : []
),
...digest.network!.dumps.map((d) => d.url),
...digest.top.map((d) => d.url),
...digest.community!.playlists.map((p) => p.url),
digest.links.followed,
digest.links.settings,
];
assertEquals(pageLinks.length, 8);
for (const link of pageLinks) {
assert(
/^\/@\w+\/(dumps|playlists)\/[a-z0-9-]+$|^\/follow$|^\/settings$/.test(
new URL(link).pathname,
),
link,
);
for (const id of [reader.id, mine.id, posted.id, added.id, mix.id]) {
assertFalse(link.includes(id), link);
}
}
assertEquals(
new URL(digest.top[1].url).pathname,
`/@${reader.username}/dumps/my-dump`,
);
});
test("buildDigest: names and links follow renames, and a deleted dump is unlinked", async () => {
const reader = await createUser();
const friend = await createUser();
const kept = insertDump({ userId: reader.id, title: "Kept" });
const gone = insertDump({ userId: reader.id, title: "Gone" });
for (const d of [kept, gone]) {
notify(reader.id, "dump_upvoted", {
voterId: friend.id,
voterUsername: friend.username,
dumpId: d.id,
dumpTitle: d.id === kept.id ? "Kept" : "Gone",
});
notify(reader.id, "dump_commented", {
commenterId: friend.id,
commenterUsername: friend.username,
commentId: crypto.randomUUID(),
dumpId: d.id,
dumpTitle: d.id === kept.id ? "Kept" : "Gone",
});
}
db.prepare(`UPDATE users SET username = 'renamed_friend' WHERE id = ?;`)
.run(friend.id);
db.prepare(`UPDATE dumps SET slug = 'still-kept' WHERE id = ?;`)
.run(kept.id);
db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(gone.id);
const [from, to] = week();
const digest =
(buildDigest(reader.id, reader.username, "weekly", from, to, "tok"))!;
const targets = digest.activity!.highlights.map((h) => [
h.who,
h.target?.title,
h.target?.url && new URL(h.target.url).pathname,
]);
assertEquals(
targets.sort((a, b) => String(a[1]).localeCompare(String(b[1]))),
[
["renamed_friend", "Gone", null],
["renamed_friend", "Kept", `/@${reader.username}/dumps/still-kept`],
],
);
}); });
test("buildDigest: the monthly recap has no personal sections, and nothing makes no digest", async () => { test("buildDigest: the monthly recap has no personal sections, and nothing makes no digest", async () => {
@@ -261,10 +333,28 @@ test("syncDigestSchedules: new users start now, a turned-off cadence loses its r
test("unsubscribe: a bad or other kind of token is refused", async () => { test("unsubscribe: a bad or other kind of token is refused", async () => {
const user = await createUser(); const user = await createUser();
assertEquals((await post("/api/users/unsubscribe?token=nope")).status, 400); for (const path of ["/api/unsubscribe", "/api/users/unsubscribe"]) {
assertEquals( assertEquals((await post(`${path}?token=nope`)).status, 400, path);
(await post(`/api/users/unsubscribe?token=${user.token}`)).status, assertEquals(
400, (await post(`${path}?token=${user.token}`)).status,
); 400,
assertEquals((await post("/api/users/unsubscribe")).status, 400); path,
);
assertEquals((await post(path)).status, 400, path);
}
});
test("unsubscribe: the v4 path and the v3 one sent emails carry both work", async () => {
const user = await createUser();
for (
const [path, cadence, setting] of [
["/api/unsubscribe", "weekly", "digestWeekly"],
["/api/users/unsubscribe", "monthly", "digestMonthly"],
] as const
) {
const token = await createUnsubscribeToken(user.id, cadence);
const res = await post<{ cadence: string }>(`${path}?token=${token}`);
assertEquals(res.data.cadence, cadence);
assertEquals(getSettings(user.id)[setting], false, path);
}
}); });

View File

@@ -53,6 +53,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ("url" in o && typeof o.url !== "string") return false; if ("url" in o && typeof o.url !== "string") return false;
// Its format is changeSlug's check, which says what is wrong.
if ("slug" in o && typeof o.slug !== "string") return false;
if ("title" in o) { if ("title" in o) {
if (typeof o.title !== "string") return false; if (typeof o.title !== "string") return false;
const trimmed = (o.title as string).trim(); const trimmed = (o.title as string).trim();
@@ -69,3 +71,20 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true; return true;
} }
/**
* Parses a multipart form's `categoryIds`, a JSON array sent as a string. A
* missing or malformed value counts as no category. The dumps and files
* routes read the forms that post and replace a file with it.
*/
export 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 [];
}
}

View File

@@ -53,6 +53,8 @@ export function isUpdatePlaylistRequest(
): obj is UpdatePlaylistRequest { ): obj is UpdatePlaylistRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
// Its format is changeSlug's check, which says what is wrong.
if ("slug" in o && typeof o.slug !== "string") return false;
if ("title" in o) { if ("title" in o) {
if (typeof o.title !== "string") return false; if (typeof o.title !== "string") return false;
if (!isValidTitle(o.title as string)) return false; if (!isValidTitle(o.title as string)) return false;

View File

@@ -1,6 +1,6 @@
/** /**
* Checks the body of `PATCH /api/users/me/settings` before the users route * Checks the body of `PATCH /api/me/settings` before `routes/me.ts` trusts
* trusts it. * it.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* Checks the JSON bodies that `routes/users.ts` receives before it reads them. * Checks the JSON bodies that the account routes (`routes/users.ts`,
* `routes/me.ts`, `routes/sessions.ts`) receive before they read them.
* *
* @module * @module
*/ */
@@ -74,7 +75,7 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
/** /**
* Checks whether `obj` is a valid {@link UpdateUserRequest}, for * Checks whether `obj` is a valid {@link UpdateUserRequest}, for
* `PATCH /api/users/me`. Every field is optional, but each one present must be * `PATCH /api/me`. Every field is optional, but each one present must be
* well-formed and within the `VALIDATION` limits. * well-formed and within the `VALIDATION` limits.
*/ */
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

14
mod.ts
View File

@@ -14,8 +14,10 @@
* other sites only through `remote/`. Each folder holds one kind of module, * other sites only through `remote/`. Each folder holds one kind of module,
* and each file is named after its domain. * and each file is named after its domain.
* *
* - `routes/` holds the HTTP API. Each router's page lists its endpoints, with * - `routes/` holds the HTTP API, one router per resource. Each router's page
* their method and path, who may call them, and what they do. * lists its endpoints, with their method and path, who may call them, and
* what they do; `API.md` at the root sums them up, with the rule they all
* follow.
* - `middleware/` holds what runs around the routes, which is auth, errors and * - `middleware/` holds what runs around the routes, which is auth, errors and
* the Open Graph tags. * the Open Graph tags.
* - `services/` holds the logic and every SQL query, one module per domain. * - `services/` holds the logic and every SQL query, one module per domain.
@@ -36,9 +38,11 @@
* *
* The types and constants both halves use sit here, one module per domain. * The types and constants both halves use sit here, one module per domain.
* They are the API envelope, each domain's types and request bodies, the * They are the API envelope, each domain's types and request bodies, the
* socket protocol, the validation limits and the role permissions. `shared/` * socket protocol, the validation limits, the role permissions, and the page
* imports nothing from either half. The server sends `Date`s, and the client * paths and slugs (`paths.ts`, `slug.ts`) the client links with and the
* reads the same types through `Wire<T>`, where every `Date` is an ISO string. * server matches. `shared/` imports nothing from either half. The server
* sends `Date`s, and the client reads the same types through `Wire<T>`, where
* every `Date` is an ISO string.
* *
* Beside it, `locales/` holds one Lingui catalogue per language for the whole * Beside it, `locales/` holds one Lingui catalogue per language for the whole
* app: the interface's messages, and the emails' that `api/mail/i18n.ts` * app: the interface's messages, and the emails' that `api/mail/i18n.ts`

View File

@@ -47,11 +47,17 @@ export interface Dump {
id: string; id: string;
kind: "url" | "file"; kind: "url" | "file";
title: string; title: string;
/** Made from the title. The dump's page URL takes it in place of the id. */ /**
slug?: string; * Names the dump in its page's path, `dumpPath` (`shared/paths.ts`) building
* it with {@link ownerUsername}. Made from the title on creation, it changes
* only when edited, and is unique among its poster's dumps.
*/
slug: string;
/** The poster's note on the dump, in Markdown. */ /** The poster's note on the dump, in Markdown. */
comment?: string; comment?: string;
userId: string; userId: string;
/** The poster's current name, which the dump's path starts with. */
ownerUsername: string;
createdAt: Date; createdAt: Date;
updatedAt?: Date; updatedAt?: Date;
url?: string; url?: string;
@@ -69,23 +75,23 @@ export interface Dump {
} }
/** /**
* Returned by `GET /api/dumps/by-url` for each dump already posted with the * Returned by `GET /api/dumps?url=` for each dump already posted with the
* link being typed, and shown by the create form's `DuplicateNotice`. It holds * link being typed, and shown by the create form's `DuplicateNotice`. It holds
* only what the notice displays. * only what the notice displays and links to.
*/ */
export interface DumpUrlMatch { export interface DumpUrlMatch {
id: string; id: string;
slug?: string; slug: string;
title: string; title: string;
/** The poster's username. */ /** The poster's username. */
username: string; ownerUsername: string;
createdAt: Date; createdAt: Date;
voteCount: number; voteCount: number;
commentCount: number; commentCount: number;
} }
/** /**
* Returned by `GET /api/preview` to the create form, which shows it before the * Returned by `GET /api/remote/preview` to the create form, which shows it before the
* link is posted. * link is posted.
*/ */
export interface LinkPreview { export interface LinkPreview {
@@ -123,6 +129,11 @@ export interface UpdateDumpRequest {
/** Ignored for a file dump. A changed link has its preview fetched again. */ /** Ignored for a file dump. A changed link has its preview fetched again. */
url?: string; url?: string;
title?: string; title?: string;
/**
* A new slug, which the edit page's slug field sends. A title change never
* changes the slug by itself.
*/
slug?: string;
/** `null` removes the note. */ /** `null` removes the note. */
comment?: string | null; comment?: string | null;
isPrivate?: boolean; isPrivate?: boolean;

View File

@@ -7,7 +7,7 @@
*/ */
/** /**
* Returned by `GET /api/follows/status` to `FollowProvider`, which then keeps * Returned by `GET /api/me/follow` to `FollowProvider`, which then keeps
* it up to date itself as the user follows and unfollows. * it up to date itself as the user follows and unfollows.
*/ */
export interface FollowStatus { export interface FollowStatus {

View File

@@ -22,10 +22,26 @@ export type NotificationType =
| "dump_commented" | "dump_commented"
| "comment_liked"; | "comment_liked";
/**
* Where a notification's dump is now, for its link (`dumpPath`). The server
* joins it each time it sends the notification, so a renamed dump or poster
* still links right; it is absent once the dump is deleted.
*/
export interface DumpRef {
dumpSlug?: string;
dumpOwnerUsername?: string;
}
/** Where a notification's playlist is now, joined as {@link DumpRef} is. */
export interface PlaylistRef {
playlistSlug?: string;
playlistOwnerUsername?: string;
}
/** /**
* Carried by a `playlist_followed` notification, sent to a playlist's owner. * Carried by a `playlist_followed` notification, sent to a playlist's owner.
*/ */
export interface PlaylistFollowedData { export interface PlaylistFollowedData extends PlaylistRef {
followerId: string; followerId: string;
followerUsername: string; followerUsername: string;
playlistId: string; playlistId: string;
@@ -42,7 +58,7 @@ export interface UserFollowedData {
* Carried by a `user_dump_posted` notification, sent to the followers of a * Carried by a `user_dump_posted` notification, sent to the followers of a
* user who posted a public dump. * user who posted a public dump.
*/ */
export interface UserDumpPostedData { export interface UserDumpPostedData extends DumpRef {
dumperId: string; dumperId: string;
dumperUsername: string; dumperUsername: string;
dumpId: string; dumpId: string;
@@ -53,7 +69,7 @@ export interface UserDumpPostedData {
* Carried by a `playlist_dump_added` notification, sent to the followers of a * Carried by a `playlist_dump_added` notification, sent to the followers of a
* public playlist when a public dump is added to it. * public playlist when a public dump is added to it.
*/ */
export interface PlaylistDumpAddedData { export interface PlaylistDumpAddedData extends DumpRef, PlaylistRef {
dumpId: string; dumpId: string;
dumpTitle: string; dumpTitle: string;
playlistId: string; playlistId: string;
@@ -61,7 +77,7 @@ export interface PlaylistDumpAddedData {
} }
/** Carried by a `dump_upvoted` notification, sent to the dump's poster. */ /** Carried by a `dump_upvoted` notification, sent to the dump's poster. */
export interface DumpUpvotedData { export interface DumpUpvotedData extends DumpRef {
voterId: string; voterId: string;
voterUsername: string; voterUsername: string;
dumpId: string; dumpId: string;
@@ -70,9 +86,11 @@ export interface DumpUpvotedData {
/** /**
* Carried by a `user_mentioned` notification, sent to a user @mentioned in a * Carried by a `user_mentioned` notification, sent to a user @mentioned in a
* comment, a dump, a playlist's description or a chat message. * comment, a dump, a playlist's description or a chat message. Its
* {@link DumpRef} is the dump's, or the comment's dump's; its
* {@link PlaylistRef} the playlist's.
*/ */
export interface UserMentionedData { export interface UserMentionedData extends DumpRef, PlaylistRef {
mentionerId: string; mentionerId: string;
mentionerUsername: string; mentionerUsername: string;
contextType: "comment" | "dump" | "playlist" | "chat"; contextType: "comment" | "dump" | "playlist" | "chat";
@@ -91,7 +109,7 @@ export interface UserMentionedData {
} }
/** Carried by a `dump_commented` notification, sent to the dump's poster. */ /** Carried by a `dump_commented` notification, sent to the dump's poster. */
export interface DumpCommentedData { export interface DumpCommentedData extends DumpRef {
commenterId: string; commenterId: string;
commenterUsername: string; commenterUsername: string;
commentId: string; commentId: string;
@@ -100,7 +118,7 @@ export interface DumpCommentedData {
} }
/** Carried by a `comment_liked` notification, sent to the comment's author. */ /** Carried by a `comment_liked` notification, sent to the comment's author. */
export interface CommentLikedData { export interface CommentLikedData extends DumpRef {
likerId: string; likerId: string;
likerUsername: string; likerUsername: string;
commentId: string; commentId: string;
@@ -109,9 +127,9 @@ export interface CommentLikedData {
} }
/** /**
* A snapshot taken when the notification is made: a name or title changed * Stored when the notification is made, so a title changed later still shows
* later still shows the old one. The notification's `type` says which member * the old one. The `…Username` fields and the refs are joined when it is sent,
* it is. * so they follow a rename. The notification's `type` says which member it is.
*/ */
export type NotificationData = export type NotificationData =
| PlaylistFollowedData | PlaylistFollowedData
@@ -125,7 +143,7 @@ export type NotificationData =
/** /**
* Pushed to its user's sockets in a `notification_created` message when it is * Pushed to its user's sockets in a `notification_created` message when it is
* made, and returned by `GET /api/notifications`. * made, and returned by `GET /api/me/notifications`.
*/ */
export interface Notification { export interface Notification {
id: string; id: string;

163
shared/paths.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Builds and parses the site's page paths, by the one rule they all follow:
* the path is a hierarchy, `@name` is a user, and 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, and a default view is the bare path.
*
* The client links every page through the builders (`src/utils/shared/urls.ts`
* wraps the dump and playlist ones), the emails prefix them with the
* frontend's URL, and the server's matchers (the Open Graph tags, the
* previews and backlinks of links to this site) read paths with
* {@link parsePagePath}. Migration 0021 rewrote the v3 links in stored text
* with the builders.
*
* @module
*/
/**
* The feed tabs, in the tab bar's order. Each is a path segment: `/hot` for
* every dump, `/music/hot` for a category. They are reserved at the root, so
* no category can take one.
*/
export const FEED_TABS = ["hot", "new", "journal", "follow"] as const;
/** One of the {@link FEED_TABS}. */
export type FeedTab = (typeof FEED_TABS)[number];
/**
* The tabs of a profile, each a segment after `/@name`. The bare `/@name` is
* the overview.
*/
export const PROFILE_TABS = [
"dumps",
"upvoted",
"playlists",
"follow",
"invitees",
"manage",
] as const;
/** One of the {@link PROFILE_TABS}. */
export type ProfileTab = (typeof PROFILE_TABS)[number];
/**
* The views of a profile's `follow` tab: the users followed at
* `/@name/follow`, the playlists at `/@name/follow/playlists`.
*/
export const FOLLOW_VIEWS = ["users", "playlists"] as const;
/** One of the {@link FOLLOW_VIEWS}. */
export type FollowView = (typeof FOLLOW_VIEWS)[number];
/**
* The tabs of the search page: the dumps at `/search`, the others at
* `/search/<tab>`.
*/
export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
/** One of the {@link SEARCH_TABS}. */
export type SearchTab = (typeof SEARCH_TABS)[number];
/** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */
export interface OwnedItem {
ownerUsername: string;
slug: string;
}
const seg = encodeURIComponent;
/**
* Returns the path of `username`'s profile, of one of its tabs, or of a view
* of the `follow` tab. The default view, `users`, is the bare tab.
*/
export function userPath(
username: string,
tab?: ProfileTab,
view?: FollowView,
): string {
const base = `/@${seg(username)}`;
if (!tab) return base;
return view && view !== FOLLOW_VIEWS[0]
? `${base}/${tab}/${view}`
: `${base}/${tab}`;
}
/** Returns the path of a dump's page, `/@owner/dumps/<slug>`. */
export function dumpPath(dump: OwnedItem): string {
return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
}
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */
export function playlistPath(playlist: OwnedItem): string {
return `${userPath(playlist.ownerUsername)}/playlists/${seg(playlist.slug)}`;
}
/**
* Returns the path of a feed: every dump's at the root, or a category's at
* `/<slug>`, on `tab`, or on the viewer's default tab without one.
*/
export function feedPath(categorySlug?: string, tab?: FeedTab): string {
const base = categorySlug ? `/${seg(categorySlug)}` : "";
return tab ? `${base}/${tab}` : base || "/";
}
/**
* Returns the path of the search page on `tab`, searching `query` when it is
* given.
*/
export function searchPath(tab: SearchTab = "dumps", query?: string): string {
const base = tab === SEARCH_TABS[0] ? "/search" : `/search/${tab}`;
return query ? `${base}?q=${seg(query)}` : base;
}
/**
* Returned by {@link parsePagePath} for a path under a user. `rest` holds the
* segments after `/@name`, a profile tab and its view.
*/
export type PagePath =
| { kind: "user"; username: string; rest: string[] }
| { kind: "dump"; username: string; slug: string; edit: boolean }
| { kind: "playlist"; username: string; slug: string };
/**
* Returns what a page path under a user names: a profile (with its tab), a
* dump (or its editor) or a playlist. It returns null for any other path, a
* feed or an app page, and for one under a user that no page answers. The
* names are decoded but not looked up, so the user or the item may not exist.
*/
export function parsePagePath(pathname: string): PagePath | null {
let segments: string[];
try {
segments = pathname.replace(/\/+$/, "").split("/").slice(1).map(
decodeURIComponent,
);
} catch {
return null;
}
const [handle, section, slug, ...more] = segments;
if (!handle?.startsWith("@") || !/^\w+$/.test(handle.slice(1))) return null;
const username = handle.slice(1);
if ((section === "dumps" || section === "playlists") && slug) {
if (section === "dumps" && more.length <= 1) {
if (more.length === 0) {
return { kind: "dump", username, slug, edit: false };
}
if (more[0] === "edit") {
return { kind: "dump", username, slug, edit: true };
}
}
if (section === "playlists" && more.length === 0) {
return { kind: "playlist", username, slug };
}
return null;
}
const rest = segments.slice(1);
if (rest.length === 0) return { kind: "user", username, rest };
if (!(PROFILE_TABS as readonly string[]).includes(rest[0])) return null;
if (rest.length === 1) return { kind: "user", username, rest };
if (
rest.length === 2 && rest[0] === "follow" &&
(FOLLOW_VIEWS as readonly string[]).includes(rest[1]) &&
rest[1] !== FOLLOW_VIEWS[0]
) return { kind: "user", username, rest };
return null;
}

View File

@@ -29,7 +29,7 @@ export interface TralbumTrack {
} }
/** /**
* Returned by `GET /api/bandcamp/tracks` for a Bandcamp album or track page, * Returned by `GET /api/remote/bandcamp` for a Bandcamp album or track page,
* when the native player plays it. * when the native player plays it.
*/ */
export interface Tralbum { export interface Tralbum {
@@ -55,7 +55,7 @@ export interface YouTubePlaylistTrack {
} }
/** /**
* Returned by `GET /api/youtube/playlist` to the player, which shows the * Returned by `GET /api/remote/youtube` to the player, which shows the
* titles and lengths of a playlist embed's videos, since the embed exposes * titles and lengths of a playlist embed's videos, since the embed exposes
* neither. * neither.
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Playlists are ordered collections of dumps their owners curate. This module * Playlists are ordered collections of dumps their owners curate. This module
* types them, with the bodies that create, edit and reorder them. The client * types them, with the bodies that create, edit and reorder them. The client
* shows them on `/playlists/:playlistId`, on profiles and in search results. * shows them on `/@owner/playlists/:slug`, on profiles and in search results.
* *
* @module * @module
*/ */
@@ -17,7 +17,13 @@ export interface Playlist {
id: string; id: string;
userId: string; userId: string;
title: string; title: string;
slug?: string; /**
* Names the playlist in its page's path, `playlistPath`
* (`shared/paths.ts`) building it with {@link ownerUsername}. Made from the
* title on creation, it changes only when edited, and is unique among its
* owner's playlists.
*/
slug: string;
/** It is written in Markdown. */ /** It is written in Markdown. */
description?: string; description?: string;
/** /**
@@ -35,24 +41,25 @@ export interface Playlist {
imageMime?: string; imageMime?: string;
/** /**
* It counts the dumps the viewer can't see too. It is undefined when the * It counts the dumps the viewer can't see too. It is undefined when the
* query didn't count them, as for a new playlist or a membership. * query didn't count them.
*/ */
dumpCount?: number; dumpCount?: number;
/** It is undefined where `dumpCount` is. */ /** The owner's current name, which the playlist's path starts with. */
ownerUsername?: string; ownerUsername: string;
} }
/** /**
* Returned by `GET /api/playlists/:playlistId` to `PlaylistDetail`, with the * Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
* dumps the caller may see, in the playlist's order. * by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in
* the playlist's order.
*/ */
export interface PlaylistWithDumps extends Playlist { export interface PlaylistWithDumps extends Playlist {
dumps: Dump[]; dumps: Dump[];
} }
/** /**
* Returned by `GET /api/playlists/by-dump/:dumpId/memberships`, one per * Returned by `GET /api/me/playlists?contains=:dumpId`, one per playlist of
* playlist of the caller's. `PlaylistMembershipPanel` ticks the playlists * the caller's. `PlaylistMembershipPanel` ticks the playlists
* with `hasDump`, in the add-to-playlist modal and the dump form. * with `hasDump`, in the add-to-playlist modal and the dump form.
*/ */
export interface PlaylistMembership { export interface PlaylistMembership {
@@ -73,6 +80,11 @@ export interface CreatePlaylistRequest {
*/ */
export interface UpdatePlaylistRequest { export interface UpdatePlaylistRequest {
title?: string; title?: string;
/**
* A new slug, which the edit form's slug field sends. A title change never
* changes the slug by itself.
*/
slug?: string;
/** `null` removes the description. */ /** `null` removes the description. */
description?: string | null; description?: string | null;
isPublic?: boolean; isPublic?: boolean;

View File

@@ -1,7 +1,7 @@
/** /**
* A user's settings follow their account from browser to browser, and the * A user's settings follow their account from browser to browser, and the
* server reads some of them itself (a digest is drawn in its reader's style). * server reads some of them itself (a digest is drawn in its reader's style).
* This module types them for `GET` and `PATCH /api/users/me/settings`; the * This module types them for `GET` and `PATCH /api/me/settings`; the
* client keeps them with its session. * client keeps them with its session.
* *
* @module * @module
@@ -10,7 +10,7 @@
import type { ColorScheme, StyleName } from "./theme.ts"; import type { ColorScheme, StyleName } from "./theme.ts";
/** /**
* Returned by `GET /api/users/me/settings`, and by a `PATCH` once applied. * Returned by `GET /api/me/settings`, and by a `PATCH` once applied.
* The client's `useSettings` reads it from the session. * The client's `useSettings` reads it from the session.
*/ */
export interface UserSettings { export interface UserSettings {
@@ -29,7 +29,7 @@ export interface UserSettings {
} }
/** /**
* Sent to `PATCH /api/users/me/settings` with the settings to change; the * Sent to `PATCH /api/me/settings` with the settings to change; the
* others keep their value. * others keep their value.
*/ */
export type UpdateSettingsRequest = Partial<UserSettings>; export type UpdateSettingsRequest = Partial<UserSettings>;

50
shared/slug.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Defines what a slug is: the readable name of a category, a dump or a
* playlist in a page URL. The server makes and checks slugs with it, and the
* edit forms prefill their slug field with {@link slugify}.
*
* @module
*/
/** The longest a dump's or a playlist's slug may be. */
export const SLUG_MAX = 60;
/**
* Matches a well-formed slug: lowercase letters and digits, in words joined by
* single hyphens. Categories, dumps and playlists share it.
*/
export const SLUG_RE: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Checks whether `slug` matches {@link SLUG_RE} and is at most `max`
* characters long.
*/
export function isValidSlug(slug: string, max: number = SLUG_MAX): boolean {
return slug.length <= max && SLUG_RE.test(slug);
}
/**
* Turns `title` into lowercase ASCII words joined by hyphens, at most `max`
* characters long, cut at a hyphen when it must be cut. Accents are dropped,
* and a title with no ASCII letter or digit gives `untitled`. The result
* always passes {@link isValidSlug}.
*/
export function slugify(title: string, max: number = SLUG_MAX): 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, "-")
.substring(0, max + 1)
.replace(/^-+/, "");
// Cut at the last hyphen within `max` rather than in the middle of a word,
// unless the first word alone is longer.
const cut = slug.length > max
? slug.substring(
0,
slug.lastIndexOf("-", max) > 0 ? slug.lastIndexOf("-", max) : max,
)
: slug;
return cut.replace(/-+$/, "") || "untitled";
}

77
shared/test/paths.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import { assertEquals } from "@std/assert";
import {
dumpPath,
feedPath,
parsePagePath,
playlistPath,
searchPath,
userPath,
} from "../paths.ts";
Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(userPath("bob"), "/@bob");
assertEquals(userPath("bob", "playlists"), "/@bob/playlists");
assertEquals(userPath("bob", "follow"), "/@bob/follow");
assertEquals(userPath("bob", "follow", "users"), "/@bob/follow");
assertEquals(
userPath("bob", "follow", "playlists"),
"/@bob/follow/playlists",
);
const item = { ownerUsername: "bob", slug: "never-gonna" };
assertEquals(dumpPath(item), "/@bob/dumps/never-gonna");
assertEquals(playlistPath(item), "/@bob/playlists/never-gonna");
assertEquals(feedPath(), "/");
assertEquals(feedPath(undefined, "hot"), "/hot");
assertEquals(feedPath("music"), "/music");
assertEquals(feedPath("music", "follow"), "/music/follow");
assertEquals(searchPath(), "/search");
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
assertEquals(searchPath("dumps", "x"), "/search?q=x");
});
Deno.test("parsePagePath: what a path under a user names", () => {
assertEquals(parsePagePath("/@bob"), {
kind: "user",
username: "bob",
rest: [],
});
assertEquals(parsePagePath("/@bob/follow/playlists/"), {
kind: "user",
username: "bob",
rest: ["follow", "playlists"],
});
assertEquals(parsePagePath("/@bob/dumps/never-gonna"), {
kind: "dump",
username: "bob",
slug: "never-gonna",
edit: false,
});
assertEquals(parsePagePath("/%40bob/dumps/x/edit"), {
kind: "dump",
username: "bob",
slug: "x",
edit: true,
});
assertEquals(parsePagePath("/@bob/playlists/mix"), {
kind: "playlist",
username: "bob",
slug: "mix",
});
for (
const other of [
"/",
"/hot",
"/music/hot",
"/search/users",
"/@",
"/@b-ob",
"/@bob/nope",
"/@bob/follow/users",
"/@bob/dumps/x/y",
"/@bob/playlists/x/edit",
"/@bob/%E0%A4%A",
]
) {
assertEquals(parsePagePath(other), null, other);
}
});

25
shared/test/slug.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { assert, assertEquals, assertFalse } from "@std/assert";
import { isValidSlug, slugify } from "../slug.ts";
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => {
assertEquals(slugify("Hello, World!"), "hello-world");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
assertEquals(slugify("日本語"), "untitled");
assertEquals(slugify(""), "untitled");
assertEquals(slugify("x".repeat(100)).length, 60);
// A long title is cut at a word boundary, never left with a hyphen.
const long = slugify(`${"word ".repeat(12)}tail`);
assertEquals(long, "word-".repeat(12).slice(0, -1));
assert(isValidSlug(long));
});
Deno.test("isValidSlug: the format categories, dumps and playlists share", () => {
assert(isValidSlug("music"));
assert(isValidSlug("lo-fi-2"));
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
assertFalse(isValidSlug(bad), bad);
}
assertFalse(isValidSlug("a".repeat(61)));
assertFalse(isValidSlug("a".repeat(51), 50));
});

View File

@@ -10,7 +10,7 @@
/** /**
* Sets what a user may do beyond the basics, through `ROLE_PERMISSIONS` * Sets what a user may do beyond the basics, through `ROLE_PERMISSIONS`
* (`shared/permissions.ts`). An admin changes it with * (`shared/permissions.ts`). An admin changes it with
* `PATCH /api/users/:userId/role`. * `PATCH /api/users/:key/role`.
*/ */
export type Role = "user" | "moderator" | "admin"; export type Role = "user" | "moderator" | "admin";
@@ -33,7 +33,10 @@ export function isRole(value: unknown): value is Role {
*/ */
export interface PublicUser { export interface PublicUser {
id: string; id: string;
/** Fixed at sign-up. */ /**
* Picked at sign-up, and changeable since, a former name staying an alias
* of the user for good. Pages write it as `@name` (`userPath`).
*/
username: string; username: string;
role: Role; role: Role;
createdAt: Date; createdAt: Date;
@@ -51,7 +54,7 @@ export interface PublicUser {
/** /**
* Returned to a user about themselves: by sign-up and sign-in in * Returned to a user about themselves: by sign-up and sign-in in
* {@link AuthResponse}, by `GET /api/users/me`, and by an avatar upload. * {@link AuthResponse}, by `GET /api/me`, and by an avatar upload.
*/ */
export interface SelfUser extends PublicUser { export interface SelfUser extends PublicUser {
/** Where password reset links go. It belongs to this account only. */ /** Where password reset links go. It belongs to this account only. */
@@ -59,7 +62,7 @@ export interface SelfUser extends PublicUser {
} }
/** /**
* Returned by `GET /api/users/search` to the editor's @mention autocomplete. * Returned by `GET /api/users?q=` to the editor's @mention autocomplete.
*/ */
export interface UserSuggestion { export interface UserSuggestion {
id: string; id: string;
@@ -70,7 +73,7 @@ export interface UserSuggestion {
/** /**
* Returned, as a flat list in sign-up order, by * Returned, as a flat list in sign-up order, by
* `GET /api/users/:username/invitees`. The profile page nests it into a tree * `GET /api/users/:key/invitees`. The profile page nests it into a tree
* by `invitedById`. * by `invitedById`.
*/ */
export interface Invitee { export interface Invitee {
@@ -82,13 +85,13 @@ export interface Invitee {
createdAt: Date; createdAt: Date;
} }
/** Sent to `POST /api/users/login` by the login page. */ /** Sent to `POST /api/sessions` by the login page. */
export interface LoginUserRequest { export interface LoginUserRequest {
username: string; username: string;
password: string; password: string;
} }
/** Sent to `POST /api/users/register` by the register page. */ /** Sent to `POST /api/users` by the register page. */
export interface RegisterUserRequest { export interface RegisterUserRequest {
username: string; username: string;
password: string; password: string;
@@ -98,9 +101,10 @@ export interface RegisterUserRequest {
} }
/** /**
* Passed to `updateUser` by the users routes and the password reset. Of a * Passed to `updateUser` by the users routes and the password reset, and sent
* client's body, `PATCH /api/users/me` takes only `description` and `email`; * by a client to `PATCH /api/me`, which takes only `description`, `email` and
* the role and the password change through their own endpoints. * `username`; the role and the password change through their own endpoints,
* and the username through `renameUser`.
*/ */
export interface UpdateUserRequest { export interface UpdateUserRequest {
username?: string; username?: string;
@@ -131,7 +135,7 @@ export interface AuthResponse extends AuthTokens {
} }
/** /**
* Returned by `GET /api/users/me/media-token` to a session from before media * Returned by `GET /api/me/media-token` to a session from before media
* tokens, which has none. * tokens, which has none.
*/ */
export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">; export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">;

View File

@@ -37,6 +37,17 @@ export const USERNAME_RE: RegExp = new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
); );
/**
* How often a slug or a username may change, counted from the aliases the
* changes left: a dump's or a playlist's slug 3 times a day, a username once
* a month. Moderators are exempt. The services refuse a change past it with a
* 429, and the forms say what the limit is.
*/
export const RENAME_LIMITS = {
SLUG: { count: 3, windowMs: 24 * 3_600_000 },
USERNAME: { count: 1, windowMs: 30 * 24 * 3_600_000 },
} as const;
/** /**
* Matches an email address well enough to catch a typo; only the reset email * Matches an email address well enough to catch a typo; only the reset email
* arriving proves it. * arriving proves it.

View File

@@ -265,6 +265,7 @@
Each rule below adds only its own size / weight / width deltas. */ Each rule below adds only its own size / weight / width deltas. */
.dump-title-input, .dump-title-input,
.profile-email-input, .profile-email-input,
.username-field-input,
.playlist-edit-input, .playlist-edit-input,
.playlist-edit-textarea { .playlist-edit-textarea {
border: var(--border-width) solid var(--color-border); border: var(--border-width) solid var(--color-border);
@@ -278,11 +279,33 @@
.dump-title-input:focus, .dump-title-input:focus,
.profile-email-input:focus, .profile-email-input:focus,
.username-field-input:focus,
.playlist-edit-input:focus, .playlist-edit-input:focus,
.playlist-edit-textarea:focus { .playlist-edit-textarea:focus {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
/* A name and its "Rename" button, the hint and errors below. */
.username-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.username-field-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.username-field-input {
flex: 1 1 10rem;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: var(--text-base);
}
.dump-title-input { .dump-title-input {
flex: 1; flex: 1;
min-width: 12rem; min-width: 12rem;
@@ -488,6 +511,27 @@
margin: 0; margin: 0;
} }
/* A slug after the path it ends, as the page's address reads. The prefix
wraps above the input on a narrow screen rather than squeezing it. */
.slug-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.5rem;
}
.slug-field-prefix {
font-size: var(--text-sm);
opacity: 0.6;
overflow-wrap: anywhere;
}
.slug-field input {
flex: 1 1 12rem;
width: auto;
min-width: 0;
}
.dump-create-title-row { .dump-create-title-row {
display: flex; display: flex;
align-items: center; align-items: center;

Some files were not shown because too many files have changed in this diff Show More