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

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 up0019UserSettings } from "./migrations/0019_user_settings.ts";
import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts";
import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
interface Migration {
name: string;
@@ -66,6 +67,7 @@ const MIGRATIONS: Migration[] = [
},
{ name: "0019_user_settings", up: up0019UserSettings },
{ 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_user_created ON dumps(user_id, created_at);
CREATE INDEX idx_dumps_slug ON dumps(slug);
CREATE UNIQUE INDEX idx_dumps_user_slug ON dumps(user_id, slug);
CREATE INDEX idx_dumps_url ON dumps(url);
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
CREATE INDEX idx_votes_user_created ON votes(user_id, created_at);
CREATE INDEX idx_playlists_user ON playlists(user_id);
CREATE INDEX idx_playlists_slug ON playlists(slug);
CREATE UNIQUE INDEX idx_playlists_user_slug ON playlists(user_id, slug);
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
@@ -231,6 +231,33 @@ CREATE TABLE digest_schedule (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE dump_slug_aliases (
user_id TEXT NOT NULL,
slug TEXT NOT NULL,
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, slug)
);
CREATE INDEX idx_dump_slug_aliases_dump ON dump_slug_aliases(dump_id, created_at);
CREATE TABLE playlist_slug_aliases (
user_id TEXT NOT NULL,
slug TEXT NOT NULL,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, slug)
);
CREATE INDEX idx_playlist_slug_aliases_playlist ON playlist_slug_aliases(playlist_id, created_at);
CREATE TABLE user_aliases (
username TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (username)
);
CREATE UNIQUE INDEX idx_user_aliases_lower ON user_aliases(lower(username));
CREATE INDEX idx_user_aliases_user ON user_aliases(user_id, created_at);
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,

View File

@@ -19,6 +19,7 @@ import {
} from "../model/auth.ts";
import { JWT_SECRET } from "../config.ts";
import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts";
import type { AuthTokens, Role } from "../../shared/user.ts";
if (!JWT_SECRET) {
throw new Error(
@@ -61,9 +62,9 @@ export async function verifyPasswordResetToken(
}
/**
* Signs a session token carrying `payload`, valid for 7 days. The users routes
* issue it on sign-up, sign-in and password change, and the client sends it as
* a bearer token.
* Signs a session token carrying `payload`, valid for 7 days.
* {@link issueTokens} issues it on sign-up, sign-in and password change, and
* the client sends it as a bearer token.
*/
export async function createJWT(
payload: Omit<AuthPayload, "exp">,
@@ -77,6 +78,24 @@ export async function createJWT(
.sign(JWT_KEY);
}
/**
* Returns a new session token and media token for `user`, as sign-up
* (`POST /api/users`), sign-in (`POST /api/sessions`) and a password change
* (`PUT /api/me/password`) answer them.
*/
export async function issueTokens(
user: { id: string; username: string; role: Role },
): Promise<AuthTokens> {
return {
token: await createJWT({
userId: user.id,
username: user.username,
role: user.role,
}),
mediaToken: await createMediaToken(user.id),
};
}
/**
* Signs a media token for `userId`, valid for 7 days. Media elements can't send
* an `Authorization` header, so the URLs of private files, thumbnails and

View File

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

View File

@@ -1,12 +1,14 @@
/**
* Recognises links to this site's own pages. The self provider previews them
* from the database, and remote/fetch.ts accepts them as dump links even on a
* private host such as localhost.
* from the database, remote/fetch.ts accepts them as dump links even on a
* private host such as localhost, and migration 0021 rewrote the v3 ones in
* stored text.
*
* @module
*/
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
import { parsePagePath } from "../../shared/paths.ts";
const SELF_HOSTS = new Set(
[PUBLIC_URL, FRONTEND_URL].flatMap((u) => {
@@ -18,14 +20,22 @@ const SELF_HOSTS = new Set(
}),
);
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
/**
* Checks whether `host` (with its port, as `URL.host` gives it) is where this
* site is served, at its public URL or its frontend's.
*/
export function isSelfHost(host: string): boolean {
return SELF_HOSTS.has(host.toLowerCase());
}
/**
* Checks whether `url` links to one of this site's own dump, user or playlist
* pages. Matched on the host as well as the path: `/users/bob` on some other
* site is someone else's page, not ours.
* pages, by `parsePagePath`; a dump's editor doesn't count. Matched on the
* host as well as the path: `/@bob` on some other site is someone else's
* page, not ours.
*/
export function isSelfUrl(url: URL): boolean {
return SELF_HOSTS.has(url.host.toLowerCase()) &&
SELF_PATH_RE.test(url.pathname);
if (!isSelfHost(url.host)) return false;
const page = parsePagePath(url.pathname);
return !!page && !(page.kind === "dump" && page.edit);
}

View File

@@ -1,38 +1,85 @@
/**
* Builds the URL slugs of dumps and playlists from their titles. The dump and
* playlist services store the slug when the record is created or retitled.
* Picks and checks the slugs of dumps and playlists, which name them in their
* page URLs under their owner: `/@bob/dumps/<slug>`, `/@bob/playlists/<slug>`.
* Each kind has its own slug space per owner, shared by the items' current
* slugs and their aliases (the slugs they had before). `services/slug-service.ts`
* changes and resolves slugs with these, and migration 0021 gave every item
* one; both pass the database, since this module doesn't open it.
*
* @module
*/
/**
* Matches a UUID. A dump or playlist route takes either an id or a slug, and
* the services test the parameter with this to know which column to look up.
*/
export const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
import type { DatabaseSync } from "node:sqlite";
import { APIException } from "./api-exception.ts";
import { isValidSlug, SLUG_MAX } from "../../shared/slug.ts";
/** A kind of item that has a slug under its owner. */
export type SlugKind = "dump" | "playlist";
/** Where each {@link SlugKind}'s slugs live. */
export const SLUG_TABLES: Record<
SlugKind,
{ items: string; aliases: string; itemId: string }
> = {
dump: { items: "dumps", aliases: "dump_slug_aliases", itemId: "dump_id" },
playlist: {
items: "playlists",
aliases: "playlist_slug_aliases",
itemId: "playlist_id",
},
};
/**
* Turns `title` into lowercase ASCII words joined by dashes, at most 60
* characters long. Accents are dropped, and a title with no ASCII letter or
* digit gives `untitled`.
* Returns the id of the item whose current slug or alias is `slug` in
* `userId`'s space for `kind`, or undefined when it is free.
*/
export function slugify(title: string): string {
const slug = title
.toLowerCase()
.normalize("NFD")
// NFD splits an accented letter into the letter and a combining mark.
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.substring(0, 60);
return slug || "untitled";
export function slugHolder(
db: DatabaseSync,
kind: SlugKind,
userId: string,
slug: string,
): string | undefined {
const { items, aliases, itemId } = SLUG_TABLES[kind];
const row = db.prepare(
`SELECT id FROM ${items} WHERE user_id = ? AND slug = ?
UNION ALL
SELECT ${itemId} FROM ${aliases} WHERE user_id = ? AND slug = ?
LIMIT 1;`,
).get(userId, slug, userId, slug) as { id: string } | undefined;
return row?.id;
}
/**
* Returns a record's slug, its title's {@link slugify} followed by the first 8
* characters of `id`. The suffix keeps two records with the same title apart.
* Returns `base`, or `base-2`, `base-3`… cut to fit, whichever is free in
* `userId`'s space for `kind`. `base` must be a valid slug, as `slugify`
* makes. The services take it on creating an item, the migration on
* re-slugging one, inside the transaction that writes it.
*/
export function makeSlug(title: string, id: string): string {
return `${slugify(title)}-${id.substring(0, 8)}`;
export function uniqueSlug(
db: DatabaseSync,
kind: SlugKind,
userId: string,
base: string,
): string {
for (let n = 1;; n++) {
const suffix = n === 1 ? "" : `-${n}`;
const candidate = `${
base.substring(0, SLUG_MAX - suffix.length).replace(/-+$/, "")
}${suffix}`;
if (!slugHolder(db, kind, userId, candidate)) return candidate;
}
}
/**
* Throws a 400 unless `slug` is a valid slug, lowercase words of letters and
* digits joined by hyphens, at most `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;
/** Who did it. */
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. */
quote?: string;
at: Date;
@@ -244,8 +247,10 @@ export function renderDigestEmail(
i18n._("email.digest.theChat"),
content.links.chat,
)
: h.target
: h.target?.url
? mdLink(h.target.title, h.target.url)
: h.target
? escMd(h.target.title)
: "";
const line = `- ${
highlightSentence(h.kind, `**@${escMd(h.who)}**`, target)

View File

@@ -1,8 +1,9 @@
/**
* 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
* middleware, the OG tags and CORS, then the routers, and last the built
* frontend, which answers every path no router did.
* scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
*
* @module
*/
@@ -11,23 +12,25 @@ import { Application } from "@oak/oak";
import { oakCors } from "@tajpouria/cors";
import attachmentsRouter from "./routes/attachments.ts";
import avatarsRouter from "./routes/avatars.ts";
import categoriesRouter from "./routes/categories.ts";
import chatRouter from "./routes/chat.ts";
import commentsRouter from "./routes/comments.ts";
import dumpsRouter from "./routes/dumps.ts";
import filesRouter from "./routes/files.ts";
import thumbnailsRouter from "./routes/thumbnails.ts";
import usersRouter from "./routes/users.ts";
import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts";
import bandcampRouter from "./routes/bandcamp.ts";
import youtubeRouter from "./routes/youtube.ts";
import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts";
import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts";
import meRouter from "./routes/me.ts";
import notificationsRouter from "./routes/notifications.ts";
import passwordResetsRouter from "./routes/password-resets.ts";
import playlistsRouter from "./routes/playlists.ts";
import remoteRouter from "./routes/remote.ts";
import searchRouter from "./routes/search.ts";
import categoriesRouter from "./routes/categories.ts";
import sessionsRouter from "./routes/sessions.ts";
import thumbnailsRouter from "./routes/thumbnails.ts";
import 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 { errorMiddleware } from "./middleware/error.ts";
@@ -55,78 +58,32 @@ app.use(async (ctx, next) => {
}
await next();
});
app.use(
dumpsRouter.routes(),
dumpsRouter.allowedMethods(),
);
app.use(
filesRouter.routes(),
filesRouter.allowedMethods(),
);
app.use(
thumbnailsRouter.routes(),
thumbnailsRouter.allowedMethods(),
);
app.use(
attachmentsRouter.routes(),
attachmentsRouter.allowedMethods(),
);
app.use(
usersRouter.routes(),
usersRouter.allowedMethods(),
);
app.use(
avatarsRouter.routes(),
avatarsRouter.allowedMethods(),
);
app.use(
wsRouter.routes(),
wsRouter.allowedMethods(),
);
app.use(
previewRouter.routes(),
previewRouter.allowedMethods(),
);
app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use(
youtubeRouter.routes(),
youtubeRouter.allowedMethods(),
);
app.use(
playlistsRouter.routes(),
playlistsRouter.allowedMethods(),
);
app.use(
commentsRouter.routes(),
commentsRouter.allowedMethods(),
);
app.use(
chatRouter.routes(),
chatRouter.allowedMethods(),
);
app.use(
followsRouter.routes(),
followsRouter.allowedMethods(),
);
app.use(
notificationsRouter.routes(),
notificationsRouter.allowedMethods(),
);
app.use(
invitesRouter.routes(),
invitesRouter.allowedMethods(),
);
app.use(
searchRouter.routes(),
searchRouter.allowedMethods(),
);
app.use(
categoriesRouter.routes(),
categoriesRouter.allowedMethods(),
);
for (
const router of [
meRouter,
followsRouter,
notificationsRouter,
avatarsRouter,
usersRouter,
sessionsRouter,
passwordResetsRouter,
unsubscribeRouter,
dumpsRouter,
filesRouter,
thumbnailsRouter,
commentsRouter,
playlistsRouter,
categoriesRouter,
searchRouter,
chatRouter,
invitesRouter,
attachmentsRouter,
remoteRouter,
wsRouter,
]
) {
app.use(router.routes(), router.allowedMethods());
}
app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`,
`${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
* path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed.
* path, as in `AuthContext<"/:key/role">`, to keep `ctx.params` typed.
*/
export type AuthContext<
R extends string = string,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/**
* Resolves a Bandcamp page to its streamable tracks when the native player
* starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and
* starts it, for `GET /api/remote/bandcamp`. The stream URLs are signed and
* expire 24 hours after the page is fetched, so unlike the embed URL they
* can't be stored in the dump's preview. The browser plays them from bcbits
* directly.

View File

@@ -1,7 +1,7 @@
/**
* Shows the thumbnails a browser can't load from their own site: those on plain
* http, which an https page would block, and those behind hotlink protection.
* It fetches and caches the images `GET /api/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
* directly failed.
*
@@ -11,7 +11,7 @@
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`.
*/
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
* takes the embed player's URL from the page's `og:video`. The native player
* fetches the page's streams itself at play time, through
* `GET /api/bandcamp/tracks`.
* `GET /api/remote/bandcamp`.
*/
export const bandcampProvider: RichContentProvider = {
name: "bandcamp",

View File

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

View File

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

View File

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

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
* for those with `category:manage`.
* Serves the categories dumps are filed under, and changes them for those with
* `category:manage`. A category's feed is `GET /api/dumps?category=:slug`.
*
* - `GET /api/categories` — public. Answers every category, in order.
* - `GET /api/categories/:slug` — public. Answers one category.
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
* a page of its dumps, newest first, with the caller's private ones.
* - `POST /api/categories` — permission `category:manage`. Creates a category.
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
* category.
@@ -17,24 +14,19 @@
import { Router } from "@oak/oak";
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Category } from "../../shared/category.ts";
import type { Dump } from "../../shared/dump.ts";
import {
isCreateCategoryRequest,
isUpdateCategoryRequest,
} from "../validators/category.ts";
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createCategory,
deleteCategory,
getCategoryBySlug,
listCategories,
listDumpsByCategory,
updateCategory,
} from "../services/category-service.ts";
@@ -52,31 +44,6 @@ router.get("/", (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:slug", (ctx) => {
const category = getCategoryBySlug(ctx.params.slug);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.body = responseBody;
});
router.get("/:slug/dumps", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = listDumpsByCategory(
ctx.params.slug,
page,
limit,
requestingUserId,
);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.post(
"/",
authMiddleware,

View File

@@ -1,11 +1,18 @@
/**
* Posts, reads, edits and deletes dumps, for the index feed, the dump page, its
* edit page and the create form. `:dumpId` takes an id or a slug. A private
* dump answers 404 to anyone but its poster, moderators included.
* Posts, reads, edits and deletes dumps, for the feeds, the dump page, its
* edit page and the create form. `:dumpId` is a dump's id; the dump page reads
* a dump by its path, through `GET /api/users/:key/dumps/:slug`. A private
* dump answers 404 to anyone but its poster, moderators included. Its file and
* thumbnail are served by `routes/files.ts` and `routes/thumbnails.ts`, its
* comments by `routes/comments.ts`.
*
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
* dumps the caller may see, newest first.
* - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in
* the category with that slug; 404 when there is none.
* - `GET /api/dumps?url=` — optional sign-in. Answers the dumps already
* pointing at the same thing as `url`, for the create form's duplicate hint.
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
@@ -14,21 +21,12 @@
* linked to or from it.
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
* playlists it appears in.
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
* dumps the caller may see, newest first.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
* title, categories and visibility (multipart).
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Updates its title, comment, visibility, categories, and a
* link's URL.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Removes its custom thumbnail.
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
* with `dump:moderate`. Fetches a link's preview again.
* `dump:moderate`. Updates its title, slug, comment, visibility, categories,
* and a link's URL. A new slug keeps the old one as an alias, 3 times a day
* at most but for those with `dump:moderate`.
* - `POST /api/dumps/:dumpId/metadata` — signed in, as its poster or with
* `dump:moderate`. Fetches a link's preview again.
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Deletes it.
*
@@ -43,6 +41,7 @@ import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
import {
isCreateUrlDumpRequest,
isUpdateDumpRequest,
parseCategoryIds,
} from "../validators/dump.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts";
@@ -52,8 +51,6 @@ import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import {
createFileDump,
createUrlDump,
@@ -62,11 +59,9 @@ import {
getDump,
listDumps,
refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump,
setDumpThumbnail,
updateDump,
} from "../services/dump-service.ts";
import { listDumpsByCategory } from "../services/category-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-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" });
// Each handler resolves `:dumpId` with getDump, which checks privacy and takes
// a slug, and passes `dump.id` on: the services' writes match on the id alone.
/**
* Parses a multipart form's `categoryIds`, a JSON array sent as a string. A
* missing or malformed value counts as no category.
*/
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string")
: [];
} catch {
return [];
}
}
// Each handler resolves `:dumpId` with getDump, which checks privacy, before
// acting on the dump.
router.post(
"/",
@@ -140,17 +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) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
@@ -194,8 +162,21 @@ router.get("/:dumpId/playlists", async (ctx) => {
router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = listDumps(page, limit, requestingUserId);
const params = ctx.request.url.searchParams;
const url = params.get("url");
if (url !== null) {
const responseBody: APIResponse<DumpUrlMatch[]> = {
success: true,
data: findDumpsByUrl(url, requestingUserId),
};
ctx.response.body = responseBody;
return;
}
const { page, limit } = parsePagination(params);
const category = params.get("category");
const { items, hasMore } = category
? listDumpsByCategory(category, page, limit, requestingUserId)
: listDumps(page, limit, requestingUserId);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
@@ -203,40 +184,6 @@ router.get("/", async (ctx) => {
ctx.response.body = responseBody;
});
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(403, "Not authorized to update dump");
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
const comment = formData.get("comment");
const title = formData.get("title");
if (!(file instanceof File)) {
throw new APIException(400, "A file is required");
}
const updatedDump = await replaceFileDump(
dump.id,
file,
typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined,
formData.has("categoryIds")
? parseCategoryIds(formData.get("categoryIds"))
: undefined,
formData.has("isPrivate")
? formData.get("isPrivate") === "true"
: undefined,
);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.put("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
@@ -252,54 +199,16 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
throw new APIException(403, "Not authorized to update dump");
}
const updatedDump = await updateDump(dump.id, body);
const updatedDump = await updateDump(
dump.id,
body,
can(ctx.state.user, "dump:moderate"),
);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(403, "Not authorized to update dump");
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
throw new APIException(400, "Missing file field");
}
const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
// Written before setDumpThumbnail announces it, or clients would fetch it
// too early.
await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data);
const updatedDump = setDumpThumbnail(dump.id, mime);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(403, "Not authorized to update dump");
}
const updatedDump = await removeDumpThumbnail(dump.id);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
router.post("/:dumpId/metadata", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);

View File

@@ -1,26 +1,39 @@
/**
* Serves file dumps' uploads to the players, viewers and download links of the
* frontend, which builds the URL with `dumpFileUrl`.
* Serves and replaces file dumps' uploads, a sub-resource of their dump. The
* players, viewers and download links of the frontend build the URL with
* `dumpFileUrl`, and its edit page replaces the file.
*
* - `GET /api/files/:dumpId?v=&token=` — public; a private dump's file needs
* its poster's media `token`. Serves the file, inline when that is safe and
* as a download otherwise, with byte ranges.
* - `GET /api/dumps/:dumpId/file?v=&token=` — public; a private dump's file
* needs its poster's media `token`. Serves the file, inline when that is
* safe and as a download otherwise, with byte ranges.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
* title, slug, categories and visibility (multipart).
*
* @module
*/
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import { DUMPS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts";
import { can } from "../lib/permissions.ts";
import { serveFile } from "../lib/serve-file.ts";
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { parseCategoryIds } from "../validators/dump.ts";
import {
dumpFileVersion,
getDump,
replaceFileDump,
} from "../services/dump-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/files" });
const router: Router = new Router({ prefix: "/api/dumps" });
/**
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
@@ -54,7 +67,7 @@ function isInlineSafe(mime: string): boolean {
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
}
router.get("/:dumpId", async (ctx) => {
router.get("/:dumpId/file", async (ctx) => {
const { dumpId } = ctx.params;
// The id names a file on disk: nothing but an id's characters gets through.
@@ -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;

View File

@@ -1,27 +1,28 @@
/**
* Serves the endpoints for what the caller follows, and the index's "followed"
* tab built from it. What another user follows is served by `routes/users.ts`.
* Serves what the caller follows, and the index's "follow" tab built from it.
* What another user follows is served by `routes/users.ts`.
*
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
* - `GET /api/me/follow` — signed in. Answers the ids of the users and
* playlists they follow.
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
* page of the public dumps of the users they follow, newest first, optionally
* in one category.
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
* - `PUT /api/me/follow/users/:userId` — signed in. Follows a user.
* - `DELETE /api/me/follow/users/:userId` — signed in. Stops following a user.
* - `PUT /api/me/follow/playlists/:playlistId` — signed in. Follows a public
* playlist.
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
* a playlist.
* - `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,
* 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
*/
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.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.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/follows" });
const router: Router = new Router({ prefix: "/api/me" });
router.get("/status", authMiddleware, (ctx) => {
router.get("/follow", authMiddleware, (ctx) => {
const status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status };
ctx.response.body = body;
});
router.get("/feed/users", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
const { items, hasMore } = categoryId === "missing"
? EMPTY_FEED
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
const data: PaginatedData<Dump> = {
items,
hasMore,
};
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
ctx.response.body = body;
});
router.get("/feed/playlists", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
const { items, hasMore } = categoryId === "missing"
? EMPTY_FEED
: getFollowedPlaylistsDumpFeed(
ctx.state.user.userId,
page,
limit,
categoryId,
);
const data: PaginatedData<Dump> = {
items,
hasMore,
};
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
ctx.response.body = body;
});
router.post("/users/:userId", authMiddleware, (ctx) => {
router.put("/follow/users/:userId", authMiddleware, (ctx) => {
followUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
router.delete("/users/:userId", authMiddleware, (ctx) => {
router.delete("/follow/users/:userId", authMiddleware, (ctx) => {
unfollowUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
router.put("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
router.delete("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});
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;

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

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

View File

@@ -1,11 +1,19 @@
/**
* Fetches link previews for the create form, and proxies the remote images
* that the frontend's thumbnails can't load directly.
* Reads other sites for the client, as `api/remote/` does for the server: the
* 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
* whether its page could be reached.
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through
* this site; 400 when it isn't one, 502 when its host can't be reached.
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
* and whether its page could be reached.
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
* through this site; 400 when it isn't one, 502 when its host can't be
* reached.
* - `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
*/
@@ -13,6 +21,8 @@
import { Router } from "@oak/oak";
import { isValidHttpUrl } from "../remote/fetch.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 {
APIErrorCode,
@@ -20,16 +30,17 @@ import {
type APIResponse,
} from "../../shared/api.ts";
import type { LinkPreview } from "../../shared/dump.ts";
import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts";
import { authMiddleware } from "../middleware/auth.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 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.
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
router.get("/preview", authMiddleware, async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) {
const responseBody: APIFailure = {
@@ -69,7 +80,7 @@ function sendImage(
// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
// page won't load, or when its host's hotlink protection refused the browser.
previewRouter.get("/api/proxy-image", async (ctx) => {
router.get("/image", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) {
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
* build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are
* generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`.
* Serves and changes dumps' thumbnails, a sub-resource of their dump. The
* cards and pages that show a file dump build the URL with
* `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, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
* - `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
*/
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import {
dumpFileVersion,
getDump,
getDumpThumbnailInfo,
removeDumpThumbnail,
setDumpThumbnail,
} from "../services/dump-service.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 { 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.
* 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
@@ -169,7 +186,7 @@ async function ensureGenerated(
return await pending.then(() => true, () => false);
}
router.get("/:dumpId", async (ctx) => {
router.get("/:dumpId/thumbnail", async (ctx) => {
const { dumpId } = ctx.params;
// 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");
});
/**
* 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;

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,
* with what each user posted, upvoted, follows and invited. A profile sent to
* anyone but its user goes through `toPublicUser`, which drops the email.
* Serves sign-up and user profiles, with what each user posted, upvoted, made,
* follows and invited, and the dumps and playlists under them. A user's `:key`
* 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
* account, sends the welcome email, and answers the user and their tokens.
* - `POST /api/users/login` — public. Answers the user and their tokens;
* rate-limited per account.
* - `GET /api/users/me` — signed in. Answers the caller's own user.
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a
* session that predates them.
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
* the account with that address, if any; always answers 200.
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
* password.
* - `POST /api/users/me/change-password` — signed in, with the current
* password. Sets a new one, ending every other session, and answers fresh
* tokens.
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
* description and email.
* - `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
* - `POST /api/users` — public, with a valid invite. Creates an account, sends
* the welcome email, and answers the user and their tokens.
* - `GET /api/users?q=` — public. Answers up to 8 users whose name starts
* with `q`, for @mention suggestions.
* - `GET /api/users/:key` — public. Answers a user's profile.
* - `GET /api/users/:key/dumps?page=&limit=` — optional sign-in. Answers a
* page of their dumps, the private ones only to themselves.
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
* - `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.
* - `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
*/
@@ -54,32 +42,20 @@ import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
type AuthResponse,
type AuthTokens,
type Invitee,
isRole,
type MediaTokenResponse,
type PublicUser,
type SelfUser,
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
fieldOf,
isLoginUserRequest,
isRegisterUserRequest,
isUpdateUserRequest,
validateRegisterUserRequest,
} from "../validators/user.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { toPublicUser, toSelfUser, type User } from "../model/user.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 { toPublicUser, toSelfUser } from "../model/user.ts";
import { issueTokens } from "../lib/jwt.ts";
import {
type AuthContext,
authMiddleware,
@@ -90,64 +66,39 @@ import { parsePagination } from "../lib/pagination.ts";
import {
createUser,
getInviteTree,
getUserById,
getUserByUsername,
renameUser,
resolveUserKey,
searchUsers,
sendWelcomeEmail,
updateUser,
} from "../services/user-service.ts";
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-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";
broadcastUserUpdated,
updateClientUsername,
} from "../services/ws-service.ts";
import {
getDumpByPath,
getDumpsByUser,
getVotedDumpsByUser,
} from "../services/dump-service.ts";
import { listPlaylistsByUser } from "../services/playlist-service.ts";
import {
getPlaylist,
getPlaylistByPath,
listPlaylistsByUser,
} from "../services/playlist-service.ts";
import {
getFollowedPlaylistsByUser,
getFollowedUsersByUser,
} 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.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/users" });
/** Allows a burst of 10 login attempts per account, then one every 30s. */
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) => {
router.post("/", async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isRegisterUserRequest(body)) {
@@ -179,208 +130,7 @@ router.post("/register", async (ctx) => {
ctx.response.body = responseBody;
});
router.post("/login", 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;
});
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) => {
router.get("/", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const responseBody: APIResponse<UserSuggestion[]> = {
success: true,
@@ -389,75 +139,16 @@ router.get("/search", (ctx) => {
ctx.response.body = responseBody;
});
router.get("/by-id/:userId", (ctx) => {
router.get("/:key", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
data: toPublicUser(getUserById(ctx.params.userId)),
data: toPublicUser(resolveUserKey(ctx.params.key)),
};
ctx.response.body = responseBody;
});
router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username);
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);
router.get("/:key/dumps", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const includePrivate = requestingUserId === user.id;
@@ -474,8 +165,18 @@ router.get("/:username/dumps", async (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:username/votes", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
router.get("/:key/dumps/:slug", async (ctx) => {
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 { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getVotedDumpsByUser(
@@ -491,4 +192,116 @@ router.get("/:username/votes", async (ctx) => {
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;

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

View File

@@ -21,7 +21,8 @@ import {
isDumpRow,
} from "../model/dump.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 { parseRows } from "../model/rows.ts";
@@ -30,7 +31,7 @@ import { parseRows } from "../model/rows.ts";
* with a message for each case.
*/
function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) {
if (!SLUG_RE.test(slug)) {
throw new APIException(
400,
"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.
*/
@@ -291,7 +292,7 @@ export function attachCategoryIds(dumps: Dump[]): void {
/**
* 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.
*
* @throws {APIException} 404 when there is no such category.

View File

@@ -22,7 +22,12 @@ import {
type DigestCadence,
} from "../../shared/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 {
type ActivityKind,
type DigestContent,
@@ -35,6 +40,7 @@ import { createUnsubscribeToken } from "../lib/jwt.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { getSettings } from "./settings-service.ts";
import { dumpFileVersion } from "./dump-service.ts";
import { withCurrentRefs } from "./notification-service.ts";
const DAY_MS = 86_400_000;
/** How long each cadence's period lasts. */
@@ -67,11 +73,6 @@ interface Window {
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. */
function quoteOf(markdown: string | undefined | null): string | 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 {
id: string;
slug: string | null;
slug: string;
title: string;
kind: string;
url: string | null;
@@ -126,7 +127,7 @@ function thumbnailOf(row: DumpRow): string | null {
createdAt: new Date(row.created_at),
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;
try {
@@ -139,7 +140,7 @@ function thumbnailOf(row: DumpRow): string | null {
if (typeof thumbnailUrl !== "string") return null;
if (thumbnailUrl.startsWith("https://")) return thumbnailUrl;
if (thumbnailUrl.startsWith("http://")) {
return `${PUBLIC_URL}/api/proxy-image?url=${
return `${PUBLIC_URL}/api/remote/image?url=${
encodeURIComponent(thumbnailUrl)
}`;
}
@@ -157,7 +158,9 @@ function toDigestDump(row: DumpRow, readerId: string): DigestDump {
}
return {
title: row.title,
url: dumpUrl(row),
url: `${FRONTEND_URL}${
dumpPath({ ownerUsername: row.poster, slug: row.slug })
}`,
poster: row.poster,
domain,
thumbnail: thumbnailOf(row),
@@ -205,7 +208,7 @@ function activityOf(
}[];
const highlights = rows.flatMap((row) => {
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) }] : [];
} catch {
return [];
@@ -219,24 +222,35 @@ function activityOf(
const commentBody = (id: string) =>
(db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`)
.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 = ?;`)
.get(id) as { id: string; slug: string | null; title: string } | undefined;
return row ? { title: row.title, url: dumpUrl(row) } : {
title: fallbackTitle,
url: `${FRONTEND_URL}/dumps/${encodeURIComponent(id)}`,
};
};
const playlistTarget = (id: string, fallbackTitle: string) => {
const row = db.prepare(`SELECT id, slug, title FROM playlists WHERE id = ?;`)
.get(id) as { id: string; slug: string | null; title: string } | undefined;
return row ? { title: row.title, url: playlistUrl(row) } : {
title: fallbackTitle,
url: `${FRONTEND_URL}/playlists/${encodeURIComponent(id)}`,
};
};
/**
* Returns a highlight's dump, titled `title`, linked where the notification's
* joined ref says it is now, or unlinked once it is deleted.
*/
const dumpTarget = (ref: DumpRef, title: string) => ({
title,
url: ref.dumpSlug && ref.dumpOwnerUsername
? `${FRONTEND_URL}${
dumpPath({ ownerUsername: ref.dumpOwnerUsername, slug: ref.dumpSlug })
}`
: null,
});
/** Returns a highlight's playlist as {@link dumpTarget} does its dump. */
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(
type: NotificationType,
// deno-lint-ignore no-explicit-any
@@ -249,20 +263,20 @@ function highlightOf(
return {
kind: "mentionComment",
who: data.mentionerUsername,
target: dumpTarget(data.dumpId, data.contextTitle),
target: dumpTarget(data, data.contextTitle),
quote: quoteOf(commentBody(data.contextId)),
};
case "dump":
return {
kind: "mentionDump",
who: data.mentionerUsername,
target: dumpTarget(data.contextId, data.contextTitle),
target: dumpTarget(data, data.contextTitle),
};
case "playlist":
return {
kind: "mentionPlaylist",
who: data.mentionerUsername,
target: playlistTarget(data.contextId, data.contextTitle),
target: playlistTarget(data, data.contextTitle),
};
case "chat":
return {
@@ -279,14 +293,14 @@ function highlightOf(
return {
kind: "comment",
who: data.commenterUsername,
target: dumpTarget(data.dumpId, data.dumpTitle),
target: dumpTarget(data, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)),
};
case "playlist_followed":
return {
kind: "playlistFollow",
who: data.followerUsername,
target: playlistTarget(data.playlistId, data.playlistTitle),
target: playlistTarget(data, data.playlistTitle),
};
case "user_followed":
return { kind: "userFollow", who: data.followerUsername };
@@ -294,7 +308,7 @@ function highlightOf(
return {
kind: "commentLike",
who: data.likerUsername,
target: dumpTarget(data.dumpId, data.dumpTitle),
target: dumpTarget(data, data.dumpTitle),
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;`,
).all(w.from, w.to) as {
id: string;
slug: string | null;
slug: string;
title: string;
owner: string;
dumps: number;
@@ -432,7 +446,9 @@ function communityOf(readerId: string, w: Window): DigestContent["community"] {
membersMore: Math.max(0, members.length - MEMBERS_MAX),
playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({
title: p.title,
url: playlistUrl(p),
url: `${FRONTEND_URL}${
playlistPath({ ownerUsername: p.owner, slug: p.slug })
}`,
owner: p.owner,
dumps: p.dumps,
})),
@@ -473,11 +489,9 @@ export function buildDigest(
community,
links: {
notifications: `${FRONTEND_URL}/notifications`,
followed: `${FRONTEND_URL}/~/followed`,
followed: `${FRONTEND_URL}${feedPath(undefined, "follow")}`,
chat: `${FRONTEND_URL}/?chat=open`,
settings: `${FRONTEND_URL}/users/${
encodeURIComponent(reader)
}/~/settings`,
settings: `${FRONTEND_URL}/settings`,
unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${
encodeURIComponent(token)
}`,
@@ -567,7 +581,7 @@ export async function sendDueDigests(now: Date = new Date()): Promise<number> {
to: row.email,
...email,
headers: {
"List-Unsubscribe": `<${PUBLIC_URL}/api/users/unsubscribe?token=${
"List-Unsubscribe": `<${PUBLIC_URL}/api/unsubscribe?token=${
encodeURIComponent(token)
}>`,
"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.
* `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.
*
* The functions that change a dump don't check who asks: the routes do first.
@@ -35,7 +36,7 @@ import {
notifyMentions,
notifyUserFollowersNewDump,
} 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 {
DUMP_ALLOWED_MIME_PREFIXES,
@@ -116,10 +117,10 @@ export async function createUrlDump(
const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId);
const categoryIds = [...new Set(request.categoryIds ?? [])];
transaction(() => {
const slug = newSlug("dump", userId, title);
db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
@@ -139,21 +140,7 @@ export async function createUrlDump(
setDumpCategories(dumpId, categoryIds);
});
const dump: Dump = {
id: dumpId,
kind: "url",
title,
slug,
comment: request.comment,
userId,
createdAt,
url: request.url,
richContent,
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds,
};
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
if (request.comment) {
@@ -195,7 +182,6 @@ export async function createFileDump(
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const finalTitle = title?.trim() || file.name;
const slug = makeSlug(finalTitle, dumpId);
const dedupedCategoryIds = [...new Set(categoryIds)];
await Deno.mkdir(DUMPS_DIR, { recursive: true });
@@ -205,6 +191,7 @@ export async function createFileDump(
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream());
transaction(() => {
const slug = newSlug("dump", userId, finalTitle);
db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
@@ -229,22 +216,7 @@ export async function createFileDump(
throw err;
}
const dump: Dump = {
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,
};
const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds };
broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
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
* throws a 404. It doesn't check privacy: the routes do, with {@link getDump}.
* Returns dump `dumpId`, without its categories, or throws a 404. It doesn't
* check privacy: the routes do, with {@link getDump}.
*/
function fetchDump(idOrSlug: string): Dump {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
: db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get(
idOrSlug,
);
function fetchDump(dumpId: string): Dump {
const row = db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`)
.get(dumpId);
if (!row || !isDumpRow(row)) {
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
* resolve every `:dumpId` with it before acting on the dump.
* Returns dump `dumpId` as `requestingUserId` may see it. The routes resolve
* every `:dumpId` with it before acting on the dump.
*
* @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs.
@@ -317,6 +286,25 @@ export function getDump(dumpId: string, requestingUserId?: string): 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
* 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
* 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
* a repost.
*/
@@ -413,7 +401,7 @@ export function findDumpsByUrl(
LIMIT ?;`,
).all(canonical, requestingUserId ?? null, limit) as {
id: string;
slug: string | null;
slug: string;
title: string;
created_at: string;
vote_count: number;
@@ -423,9 +411,9 @@ export function findDumpsByUrl(
return rows.map((row) => ({
id: row.id,
slug: row.slug ?? undefined,
slug: row.slug,
title: row.title,
username: row.username,
ownerUsername: row.username,
createdAt: new Date(row.created_at),
voteCount: row.vote_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
* 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
* public. Anyone @mentioned in the note is notified.
*
* @throws {APIException} 404 when there is no such dump, 400 when a new URL
* isn't a public http(s) URL or a category doesn't exist.
* 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(
dumpId: string,
request: UpdateDumpRequest,
exempt = false,
): Promise<Dump> {
const dump = fetchDump(dumpId);
if (request.categoryIds !== undefined) {
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();
if (dump.kind === "file") {
const title = request.title?.trim() || dump.title;
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = {
...dump,
title,
@@ -469,10 +468,9 @@ export async function updateDump(
updatedAt: now,
};
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(
updatedDump.title,
updatedDump.slug ?? null,
updatedDump.comment ?? null,
updatedDump.isPrivate ? 1 : 0,
now.toISOString(),
@@ -504,10 +502,6 @@ export async function updateDump(
const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) {
throw new APIException(400, "Invalid URL");
}
let { richContent, title } = dump;
if (newUrl !== dump.url) {
@@ -519,11 +513,10 @@ export async function updateDump(
title = request.title.trim();
}
const newSlug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = {
...dump,
title,
slug: newSlug,
slug,
comment: "comment" in request
? (request.comment ?? undefined)
: dump.comment,
@@ -537,10 +530,9 @@ export async function updateDump(
const row = dumpApiToRow(updatedDump);
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(
row.title,
row.slug,
row.comment,
row.url,
row.url ? canonicalizeUrl(row.url) : null,
@@ -581,14 +573,17 @@ export async function updateDump(
/**
* Replaces a file dump's upload and its comment, for
* `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left
* out stays as it is. Visibility is taken here so that a new file and a change
* of visibility land together. The old file is put back if the database update
* fails, and clients are told as {@link updateDump} tells them.
* `PUT /api/dumps/:dumpId/file`. A `title`, `slug`, `categoryIds` or
* `isPrivate` left out stays as it is. Visibility is taken here so that a new
* file and a change of visibility land together. The slug changes as in
* {@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
* 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(
dumpId: string,
@@ -597,6 +592,8 @@ export async function replaceFileDump(
title?: string,
categoryIds?: string[],
isPrivate?: boolean,
slug?: string,
exempt = false,
): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) {
@@ -611,6 +608,7 @@ export async function replaceFileDump(
throw new APIException(400, "Not a file dump");
}
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
// 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 finalTitle = title?.trim() || dump.title;
const newSlug = finalTitle !== dump.title
? makeSlug(finalTitle, dumpId)
: dump.slug;
const finalPrivate = isPrivate ?? dump.isPrivate;
try {
transaction(() => {
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(
finalTitle,
newSlug ?? null,
file.name,
file.type,
file.size,
@@ -680,7 +674,7 @@ export async function replaceFileDump(
const updatedDump: Dump = {
...dump,
title: finalTitle,
slug: newSlug,
slug: slug ?? dump.slug,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
@@ -694,7 +688,7 @@ export async function replaceFileDump(
/**
* 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.
*
* @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
* `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.
*
* @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
* 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
* 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.
*/
export function getDumpThumbnailInfo(
idOrSlug: string,
dumpId: string,
): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug);
const dump = fetchDump(dumpId);
return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime }
: 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
* playlists included, for `GET /api/follows/status`.
* playlists included, for `GET /api/me/follow`.
*
* @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,
* 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
*/

View File

@@ -3,7 +3,9 @@
* `notification_created`, and reads them back for `routes/notifications.ts`.
* The services of follows, votes, likes, comments, dumps, playlists and 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
*/
@@ -24,6 +26,58 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.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`,
* a second one for the same `(userId, sourceKey)` is neither stored nor sent.
@@ -62,7 +116,7 @@ function createNotification(
id,
userId,
type,
data,
data: withCurrentRefs(data),
read: false,
createdAt: now,
},
@@ -71,8 +125,8 @@ function createNotification(
}
/**
* Returns a page of `userId`'s notifications, newest first, for
* `GET /api/notifications`.
* Returns a page of `userId`'s notifications, newest first, with their refs
* joined, for `GET /api/me/notifications`.
*
* @throws {APIException} 500 when a row doesn't parse.
*/
@@ -89,7 +143,13 @@ export function getNotificationsForUser(
if (!rawRows.every(isNotificationRow)) {
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,
};
const dataJson = JSON.stringify(data);
const sent = withCurrentRefs(data);
const now = new Date();
const createdAt = now.toISOString();
const sourceKey = `dump:${dumpId}`;
@@ -227,7 +288,7 @@ export function notifyUserFollowersNewDump(
id,
userId: row.follower_id,
type: "user_dump_posted",
data,
data: sent,
read: false,
createdAt: now,
},

View File

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

View File

@@ -34,7 +34,7 @@ import {
notifyMentions,
notifyPlaylistFollowersNewDump,
} 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 {
isMemberVisibleTo,
@@ -50,25 +50,34 @@ import { parseRows } from "../model/rows.ts";
*/
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(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
* that shows it to someone must check `isPublic` first, as the OG middleware
* does.
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
*
* @throws {APIException} 404 when there is no such playlist.
*/
export function getPlaylistById(idOrSlug: string): Playlist {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug)
: db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug);
export function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
.get(playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
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
* public, it notifies the users its description mentions. It links the
@@ -81,28 +90,21 @@ export function createPlaylist(
const id = crypto.randomUUID();
const createdAt = new Date();
const title = req.title.trim();
const slug = makeSlug(title, id);
db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
id,
userId,
title,
slug,
req.description ?? null,
req.isPublic ? 1 : 0,
createdAt.toISOString(),
);
const playlist: Playlist = {
id,
userId,
title,
slug,
description: req.description,
isPublic: req.isPublic,
createdAt,
};
transaction(() => {
db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
id,
userId,
title,
newSlug("playlist", userId, title),
req.description ?? null,
req.isPublic ? 1 : 0,
createdAt.toISOString(),
);
});
const playlist = getPlaylistById(id);
if (req.description) {
// A private playlist's mentions are notified if it goes public.
if (req.isPublic) {
@@ -116,17 +118,20 @@ export function createPlaylist(
/**
* Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a
* signed-out viewer.
* order, for `GET /api/playlists/:playlistId`, and for
* `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
* and not theirs.
*/
export function getPlaylist(
playlistId: string,
playlistId: string | Playlist,
requestingUserId: string | null,
): PlaylistWithDumps {
const playlist = getPlaylistById(playlistId);
const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId)
: playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
throw new APIException(404, "Playlist not found");
@@ -176,7 +181,7 @@ export function searchPlaylists(
/**
* 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`.
*/
export function listPlaylistsByUser(
@@ -198,13 +203,16 @@ export function listPlaylistsByUser(
/**
* 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
* other than the owner edit it. If the 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`.
* it as it now is. A new `slug` goes through `changeSlug`, first, and a
* moderator is exempt from its rate limit; a new title leaves the slug as it
* is. `canModerate` lets someone other than the owner edit it. If the
* 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
* 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(
playlistId: string,
@@ -226,13 +234,15 @@ export function updatePlaylist(
? req.isPublic
: playlist.isPublic;
if (req.slug !== undefined) {
changeSlug("playlist", playlist, req.slug, canModerate);
}
const now = new Date();
const newSlug = makeSlug(newTitle, playlist.id);
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(
newTitle,
newSlug,
newDescription,
newIsPublic ? 1 : 0,
now.toISOString(),
@@ -242,7 +252,7 @@ export function updatePlaylist(
const updated: Playlist = {
...playlist,
title: newTitle,
slug: newSlug,
slug: req.slug ?? playlist.slug,
description: newDescription ?? undefined,
isPublic: newIsPublic,
updatedAt: now,
@@ -487,8 +497,8 @@ export function reorderPlaylist(
/**
* Returns each of `userId`'s playlists, newest first, and whether it holds
* dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The
* client's add-to-playlist modal shows it. The playlists have no dump count.
* dump `dumpId`, for `GET /api/me/playlists?contains=`. The client's
* add-to-playlist modal shows it.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/
@@ -497,9 +507,11 @@ export function getPlaylistMembershipsForDump(
userId: string,
): PlaylistMembership[] {
const rows = db.prepare(
`SELECT p.*, pd.dump_id IS NOT NULL as has_dump
FROM playlists p
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ?
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count,
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 = ?
ORDER BY p.created_at DESC;`,
).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 as private, so no shared cache keeps it.
*
@@ -544,10 +556,10 @@ export function getPlaylistsForDump(
* and `requestingUserId` isn't its owner.
*/
export function getPlaylistImageInfo(
idOrSlug: string,
playlistId: string,
requestingUserId: string | null,
): { id: string; imageMime: string; isPublic: boolean } | undefined {
const playlist = getPlaylistById(idOrSlug);
const playlist = getPlaylistById(playlistId);
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
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
* 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.
*
* @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
* false.
*/

View File

@@ -1,6 +1,6 @@
/**
* Reads and writes each user's settings in `user_settings`. The users routes
* answer `GET` and `PATCH /api/users/me/settings` with it, and the digests read
* Reads and writes each user's settings in `user_settings`. `routes/me.ts`
* answers `GET` and `PATCH /api/me/settings` with it, and the digests read
* their readers' choices from the same table.
*
* @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,
userRowToApi,
} from "../model/user.ts";
import { db } from "../db/connection.ts";
import { db, transaction } from "../db/connection.ts";
import { closeUserSockets } from "./ws-service.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 { isEmailEnabled, sendEmail } from "./email-service.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. */
const USER_SELECT =
@@ -40,13 +45,34 @@ const USER_SELECT =
FROM users u
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
* `POST /api/users/register`. The caller has validated `request` and claimed
* the invite.
* `POST /api/users`. The caller has validated `request` and claimed the
* invite.
*
* @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(
request: RegisterUserRequest,
@@ -55,14 +81,7 @@ export async function createUser(
const userId = crypto.randomUUID();
const createdAt = new Date();
const existingUser = db.prepare(
"SELECT id FROM users WHERE lower(username) = lower(?);",
).get(request.username);
if (existingUser) {
throw new APIException(409, "Username already exists");
}
assertUsernameAvailable(request.username);
assertEmailAvailable(request.email);
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
* matches case-sensitively.
* Returns the user named `username` now, in any case, with their hash and
* email. Signing in takes it: a former name doesn't sign in.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getUserByUsername(username: string): User {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.username = ?`,
`${USER_SELECT} WHERE lower(u.username) = lower(?)`,
).get(username);
if (!userRow || !isUserRow(userRow)) {
@@ -161,6 +180,90 @@ export function getUserByUsername(username: string): User {
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
* 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
* @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(
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
* `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
* the caller must issue fresh tokens if the user is to stay signed in. A
* `description` of null keeps the current one.
@@ -251,7 +356,8 @@ export async function updateUser(
): Promise<User> {
const user = getUserById(userId);
const { password, description, email, ...requestFields } = request;
const { password, description, email, username: _, ...requestFields } =
request;
if (email !== undefined && email !== user.email) {
assertEmailAvailable(email, userId);
}
@@ -271,9 +377,8 @@ export async function updateUser(
let userResult;
try {
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(
updatedUserRow.username,
updatedUserRow.password_hash,
updatedUserRow.role,
updatedUserRow.description,
@@ -306,7 +411,7 @@ export async function updateUser(
/**
* 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.
*
* @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
* 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[] {
const rows = db.prepare(`

View File

@@ -83,6 +83,18 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
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
* `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 {
n: number;
};
assertEquals(applied.n, 20);
assertEquals(applied.n, 21);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
20,
21,
);
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 { createJWT, createMediaToken } from "../lib/jwt.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 { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../remote/image-proxy.ts";
@@ -36,6 +38,9 @@ const TABLES = [
"chat_messages",
"user_settings",
"digest_schedule",
"dump_slug_aliases",
"playlist_slug_aliases",
"user_aliases",
"categories",
"playlists",
"dumps",
@@ -147,16 +152,24 @@ export interface InsertDumpOptions {
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
* 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(
opts: InsertDumpOptions,
): { id: string; slug: string } {
): { id: string; slug: string; path: string } {
const id = crypto.randomUUID();
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 url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null;
const data = opts.fileData ?? new TextEncoder().encode("hello");
@@ -184,15 +197,20 @@ export function insertDump(
Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true });
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(
opts: { userId: string; title?: string; isPublic?: boolean },
): { id: string; slug: string } {
): { id: string; slug: string; path: string } {
const id = crypto.randomUUID();
const title = opts.title ?? "A playlist";
const slug = makeSlug(title, id);
const slug = uniqueSlug(db, "playlist", opts.userId, slugify(title));
db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?);`,
@@ -204,7 +222,11 @@ export function insertPlaylist(
opts.isPublic === false ? 0 : 1,
new Date().toISOString(),
);
return { id, slug };
return {
id,
slug,
path: playlistPath({ ownerUsername: usernameOf(opts.userId), slug }),
};
}
export function insertComment(

View File

@@ -12,7 +12,7 @@ test("authenticate: a deleted user's token is refused", async () => {
const user = await createUser();
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
assertEquals(await authenticate(user.token), null);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
assertEquals((await get("/api/me", { auth: user.auth })).status, 401);
});
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 () => {
const owner = await createUser();
// 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);
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
assertEquals(

View File

@@ -1,16 +1,13 @@
import "../setup.ts";
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", () => {
assert(isValidSlugFormat("music"));
assert(isValidSlugFormat("lo-fi-2"));
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
assertFalse(isValidSlugFormat(bad), bad);
Deno.test("category slugs: the feed tabs, app pages and server paths are reserved", () => {
for (const word of ["hot", "follow", "settings", "manage", "api", "ws"]) {
assert(isReservedSlug(word), word);
}
// 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", () => {
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit")));
assert(isSelfUrl(new URL("http://gerbeur.test/@bob/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/@bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/@bob/playlists")));
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://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 { assert, assertEquals, assertFalse } from "@std/assert";
import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts";
import { assertEquals, assertThrows } from "@std/assert";
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", () => {
assertEquals(slugify("Hello, World!"), "hello-world");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
assertEquals(slugify("日本語"), "untitled");
assertEquals(slugify(""), "untitled");
assertEquals(slugify("x".repeat(100)).length, 60);
Deno.test("uniqueSlug: numbers a clash within the owner's space, aliases included", () => {
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE dumps (id TEXT, user_id TEXT, slug TEXT);
CREATE TABLE dump_slug_aliases (user_id TEXT, slug TEXT, dump_id TEXT);
INSERT INTO dumps VALUES ('1', 'bob', 'mix'), ('2', 'bob', 'mix-2');
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", () => {
const id = "0123abcd-0000-4000-8000-000000000000";
assertEquals(makeSlug("My Song", id), "my-song-0123abcd");
assert(UUID_RE.test(id));
assertFalse(UUID_RE.test("my-song-0123abcd"));
Deno.test("validateSlug: lowercase words joined by hyphens, 60 at most", () => {
validateSlug("never-gonna-2");
for (const bad of ["Never", "-a", "a-", "a--b", "a b", "", "a".repeat(61)]) {
assertThrows(() => validateSlug(bad), APIException, undefined, bad);
}
});

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import {
BASE_URL,
createUser,
db,
insertDump,
insertPlaylist,
test,
@@ -40,7 +41,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => {
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");
assertStringIncludes(
text,
@@ -61,7 +62,7 @@ test("ogMiddleware: dump pages get escaped OG tags", async () => {
assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes(
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.
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 () => {
const user = await createUser();
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, `<meta name="twitter:card" content="summary" />`);
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",
});
assertStringIncludes(
(await handle(ogApp(), `/dumps/${file.id}`)).text,
`content="${BASE_URL}/api/files/${file.id}"`,
(await handle(ogApp(), file.path)).text,
`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 app = ogApp();
assertStringIncludes(
(await handle(app, `/users/${user.username}/~/votes`)).text,
(await handle(app, `/@${user.username}/upvoted`)).text,
`<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" });
assertStringIncludes(
(await handle(app, `/playlists/${pub.slug}`)).text,
(await handle(app, pub.path)).text,
"Open list",
);
const priv = insertPlaylist({
@@ -103,7 +108,7 @@ test("ogMiddleware: users and public playlists; private things fall through", as
isPublic: false,
});
assertEquals(
(await handle(app, `/playlists/${priv.slug}`)).text,
(await handle(app, priv.path)).text,
"fallthrough",
);
const secret = insertDump({
@@ -111,11 +116,48 @@ test("ogMiddleware: users and public playlists; private things fall through", as
title: "Secret",
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/nope`)).text, "fallthrough");
assertEquals(
(await handle(app, `/api/dumps/${secret.id}`)).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,
vote_count: 3,
comment_count: 2,
owner_username: "bob",
is_private: 1,
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");
});
const content = await fetchRichContent(
`http://gerbeur.test/dumps/${dump.slug}`,
`http://gerbeur.test${dump.path}`,
);
assertEquals(content?.title, "Local dump");
assertEquals(content?.description, "About it");
assertEquals(content?.thumbnailUrl, "https://t/x.jpg");
assertEquals(content?.siteName, "gerbeur");
const profile = await fetchRichContent(
`http://gerbeur.test/users/${user.username}`,
`http://gerbeur.test/@${user.username}/playlists`,
);
assertEquals(profile?.title, user.username);
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");
});
const content = await fetchRichContent(
`http://gerbeur.test/playlists/${playlist.id}`,
`http://gerbeur.test${playlist.path}`,
);
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 { assertEquals, assertFalse } from "@std/assert";
test("avatars: upload, serve, and 404 when there is none", async () => {
const user = await createUser();
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
const res = await post<SelfUser>("/api/avatars/me", {
assertEquals((await get(`/api/users/${user.id}/avatar`)).status, 404);
assertEquals(
(await get(`/api/users/${crypto.randomUUID()}/avatar`)).status,
404,
);
const res = await put<SelfUser>("/api/me/avatar", {
auth: user.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 200);
assertEquals(res.data.avatarMime, "image/png");
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.bytes, PNG);
assertEquals(
(await post("/api/avatars/me", {
(await put("/api/me/avatar", {
auth: user.auth,
form: formWithFile(new Uint8Array([1, 2, 3])),
})).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 { 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: "alpha", position: 1 });
insertCategory({ slug: "beta", position: 0 });
@@ -24,11 +24,6 @@ test("categories: listed by position, then slug; found by slug", async () => {
"zeta",
"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 () => {
@@ -74,7 +69,8 @@ test("categories: slug and name rules, and friendly conflicts", async () => {
for (
const [json, message] of [
[{ 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: "ok", name: " " }, "Name is required"],
[{ 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);
});
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 cat = insertCategory({ slug: "music" });
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);
}
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), [
"in",
]);
assertEquals(anon.data.items[0].categoryIds, [cat]);
assertEquals(
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
(await get<PaginatedData<Dump>>("/api/dumps?category=music", {
auth: owner.auth,
})).data
.items.length,
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.
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 { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
assertEquals,
assertExists,
assertFalse,
assertMatch,
assertNotEquals,
} from "@std/assert";
import { assertEquals, assertExists, assertFalse } from "@std/assert";
const PAGE = html(`<title>Scraped title</title>
<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;
assertEquals(dump.kind, "url");
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.ownerUsername, user.username);
assertEquals(dump.isPrivate, false);
assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg");
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.comment, "tiny");
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.bytes, PNG);
});
@@ -221,11 +216,12 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
// ── 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 { id, slug } = insertDump({ userId: user.id, title: "Hello" });
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/${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 other = await createUser();
insertDump({
@@ -308,24 +304,24 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
isPrivate: true,
});
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), [
"public",
]);
assertEquals(anon.data[0].username, owner.username);
const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
assertEquals(anon.data[0].ownerUsername, owner.username);
const mine = await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
(await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
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: {
url: "https://example.com/src",
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;
@@ -416,6 +412,48 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
// ── 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 () => {
const owner = 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,
401,
);
const res = await put<Dump>(`/api/dumps/${slug}`, {
const res = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { title: "New title", comment: "hello" },
});
assertEquals(res.status, 200);
assertEquals(res.data.title, "New title");
assertMatch(res.data.slug ?? "", /^new-title-/);
assertNotEquals(res.data.slug, slug);
// A new title never changes the slug by itself.
assertEquals(res.data.slug, slug);
assertExists(res.data.updatedAt);
const byMod = await put(`/api/dumps/${id}`, {
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" });
assertEquals(res.data.isPrivate, true);
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.
res = await replace({});
assertEquals(res.data.isPrivate, true);
@@ -677,7 +715,7 @@ test("custom thumbnails: set, served, removed", async () => {
});
assertEquals(set.status, 200);
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.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
@@ -688,10 +726,10 @@ test("custom thumbnails: set, served, removed", async () => {
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
// 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 { id } = insertDump({
userId: owner.id,
@@ -707,7 +745,7 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
using _ = stubNetwork(() => {
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,
});
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());
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
const res = await post<Dump>(`/api/dumps/${id}/metadata`, {
auth: owner.auth,
});
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" });
assertEquals(
(await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth }))
(await post(`/api/dumps/${file.id}/metadata`, { auth: owner.auth }))
.status,
400,
);

View File

@@ -9,7 +9,7 @@ function createdAtOf(id: string): string {
}).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 { id } = insertDump({
userId: user.id,
@@ -17,7 +17,7 @@ test("GET /api/files/:id: headers for a safe inline type", async () => {
fileMime: "text/plain",
fileData: BYTES,
});
const res = await get(`/api/files/${id}`);
const res = await get(`/api/dumps/${id}/file`);
assertEquals(res.status, 200);
assertEquals(res.bytes, BYTES);
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/"');
// 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 },
});
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.
const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`;
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",
);
// A stale version is revalidated like no version at all.
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",
);
});
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();
for (const mime of ["text/html", "image/svg+xml", "application/xml"]) {
const { id } = insertDump({
@@ -60,7 +60,7 @@ test("GET /api/files/:id: active content downloads instead of rendering", async
fileName: "x",
fileMime: mime,
});
const res = await get(`/api/files/${id}`);
const res = await get(`/api/dumps/${id}/file`);
assertStringIncludes(
res.headers.get("content-disposition")!,
"attachment;",
@@ -72,19 +72,19 @@ test("GET /api/files/:id: active content downloads instead of rendering", async
fileName: "a.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;");
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 { id } = insertDump({
userId: user.id,
fileName: 'mix "été" 🎵.mp3',
fileMime: "audio/mpeg",
});
const res = await get(`/api/files/${id}`);
const res = await get(`/api/dumps/${id}/file`);
assertEquals(res.status, 200);
assertEquals(
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 { id } = insertDump({
userId: user.id,
@@ -102,7 +102,7 @@ test("GET /api/files/:id: byte ranges", async () => {
fileData: BYTES,
});
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");
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 other = await createUser();
const { id } = insertDump({
@@ -164,28 +164,31 @@ test("GET /api/files/:id: private files need the owner's media token", async ()
fileName: "p",
isPrivate: true,
});
assertEquals((await get(`/api/files/${id}`)).status, 404);
assertEquals((await get(`/api/dumps/${id}/file`)).status, 404);
assertEquals(
(await get(`/api/files/${id}?token=${other.mediaToken}`)).status,
(await get(`/api/dumps/${id}/file?token=${other.mediaToken}`)).status,
404,
);
assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404);
const res = await get(`/api/files/${id}?token=${owner.mediaToken}`);
assertEquals((await get(`/api/dumps/${id}/file?token=garbage`)).status, 404);
const res = await get(`/api/dumps/${id}/file?token=${owner.mediaToken}`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, no-cache");
// A legacy session token still works in the query string.
assertEquals(
(await get(`/api/files/${id}?token=${owner.token}`)).status,
(await get(`/api/dumps/${id}/file?token=${owner.token}`)).status,
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();
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 });
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" });
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,
insertPlaylist,
post,
put,
test,
} from "../helpers.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 them = await createUser();
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,
);
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,
);
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,
);
assertEquals(
(await post(`/api/follows/users/${crypto.randomUUID()}`, {
(await put(`/api/me/follow/users/${crypto.randomUUID()}`, {
auth: me.auth,
})).status,
404,
);
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: [] },
);
// 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(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
(await get<FollowStatus>("/api/me/follow", { auth: me.auth })).data
.followedUserIds,
[],
);
await post(`/api/follows/users/${them.id}`, { auth: me.auth });
await put(`/api/me/follow/users/${them.id}`, { auth: me.auth });
assertEquals(
count(
`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,
);
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), [
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 priv = insertPlaylist({ userId: owner.id, isPublic: false });
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,
);
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,
);
assertEquals(
(await post(`/api/follows/playlists/${crypto.randomUUID()}`, {
(await put(`/api/me/follow/playlists/${crypto.randomUUID()}`, {
auth: me.auth,
})).status,
404,
);
await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth });
await put(`/api/me/follow/playlists/${pub.id}`, { auth: owner.auth });
assertEquals(
count(
`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(
(await get<PaginatedData<Playlist>>(
`/api/users/${me.username}/followed-playlists`,
`/api/users/@${me.username}/follow/playlists`,
)).data
.items[0].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(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
(await get<FollowStatus>("/api/me/follow", { auth: me.auth })).data
.followedPlaylistIds,
[],
);
@@ -133,27 +135,27 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
.run(tagged.id, music);
insertDump({ userId: followed.id, title: "private", isPrivate: true });
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,
});
assertEquals(titles(feed), ["new", "old"]);
assertEquals(feed.data.items[0].categoryIds, [music]);
assertEquals(
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"],
);
// An unknown category filters everything out rather than nothing.
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,
})).data,
{ 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 () => {
@@ -168,11 +170,11 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
isPrivate: true,
});
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/${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,
});
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.
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
assertEquals(
(await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
(await get<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
auth: me.auth,
})).data
.items.length,

View File

@@ -1,5 +1,18 @@
import { count, createUser, get, insertDump, post, test } from "../helpers.ts";
import type { Notification } from "../../../shared/notification.ts";
import {
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 { 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 { id: dumpId } = insertDump({ userId: me.id, title: "Mine" });
// 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`, {
auth: other.auth,
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,
});
assertEquals(list.data.items.length, 2);
@@ -29,7 +42,7 @@ test("notifications: listed newest first, marked read one or all, never someone
);
assertEquals(
(await post("/api/notifications/read-all", { auth: me.auth })).status,
(await post("/api/me/notifications/read", { auth: me.auth })).status,
204,
);
assertEquals(
@@ -39,5 +52,49 @@ test("notifications: listed newest first, marked read one or all, never someone
),
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);
}
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 res = await post<Playlist>("/api/playlists", {
auth: user.auth,
@@ -34,12 +34,16 @@ test("POST /api/playlists creates; GET finds it by id or slug", async () => {
});
assertEquals(res.status, 201);
const { id, slug } = res.data;
assertMatch(slug ?? "", /^road-trip-/);
assertEquals(slug, "road-trip");
assertEquals(res.data.ownerUsername, user.username);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
"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.ownerUsername, user.username);
assertEquals(bySlug.data.dumps, []);
@@ -69,7 +73,7 @@ test("private playlists are the owner's alone", async () => {
200,
);
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, { auth: owner.auth })).data.items
@@ -220,8 +224,28 @@ test("removing a dump; updating; deleting", async () => {
json: { title: "New", description: "d", isPublic: false },
});
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);
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}`, {
auth: owner.auth,
json: { description: null },
@@ -253,7 +277,7 @@ test("memberships: which of my playlists hold this dump", async () => {
const d = insertDump({ userId: owner.id });
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
const res = await get<PlaylistMembership[]>(
`/api/playlists/by-dump/${d.id}/memberships`,
`/api/me/playlists?contains=${d.id}`,
{
auth: owner.auth,
},
@@ -273,7 +297,7 @@ test("adding to a followed public playlist notifies followers, private dumps exc
const owner = await createUser();
const follower = await createUser();
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
const priv = insertDump({ userId: owner.id, isPrivate: true });
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 other = await createUser();
const { id, slug } = insertPlaylist({ userId: owner.id });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
const { id } = insertPlaylist({ userId: owner.id });
assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404);
assertEquals(
(await post(`/api/playlists/${id}/image`, {
(await put(`/api/playlists/${id}/cover`, {
auth: other.auth,
form: formWithFile(PNG),
}))
.status,
403,
);
const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
const res = await put<Playlist>(`/api/playlists/${id}/cover`, {
auth: owner.auth,
form: formWithFile(PNG),
});
@@ -317,37 +341,37 @@ test("playlist images: upload and serve", async () => {
assertEquals(res.data.imageMime, "image/png");
// A new cover counts as an edit, so image URLs versioned on it change.
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.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 other = await createUser();
const { id } = insertPlaylist({ userId: owner.id, isPublic: false });
await post(`/api/playlists/${id}/image`, {
await put(`/api/playlists/${id}/cover`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404);
assertEquals(
(await get(`/api/playlists/${id}/image?token=${other.mediaToken}`)).status,
(await get(`/api/playlists/${id}/cover?token=${other.mediaToken}`)).status,
404,
);
assertEquals(
(await get(`/api/playlists/${id}/image?token=garbage`)).status,
(await get(`/api/playlists/${id}/cover?token=garbage`)).status,
404,
);
const served = await get(
`/api/playlists/${id}/image?token=${owner.mediaToken}`,
`/api/playlists/${id}/cover?token=${owner.mediaToken}`,
);
assertEquals(served.status, 200);
assertEquals(served.bytes, PNG);
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 { id } = insertPlaylist({ userId: owner.id });
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.
await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true });
try {
const failed = await post(`/api/playlists/${id}/image`, {
const failed = await put(`/api/playlists/${id}/cover`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(failed.status, 500);
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 {
await Deno.remove(`${dir}/${id}`, { recursive: true });
}
// With a cover in place, a failed replacement keeps serving the old one
// 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,
form: formWithFile(PNG),
});
const before = row();
await Deno.chmod(dir, 0o555);
try {
const failed = await post(`/api/playlists/${id}/image`, {
const failed = await put(`/api/playlists/${id}/cover`, {
auth: owner.auth,
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);
}
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)]
.map((e) => e.name)
.filter((name) => name.startsWith(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 { 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,
form: formWithFile(PNG),
});
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
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,
form: formWithFile(other),
});
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 () => {

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 video = insertDump({
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]) {
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.headers.get("content-type"), "image/jpeg");
assertEquals(res.headers.get("cache-control"), "public, no-cache");
const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`;
assertEquals(
(await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).headers.get(
"cache-control",
),
(await get(`/api/dumps/${video.id}/thumbnail?v=${v(video.id)}`)).headers
.get(
"cache-control",
),
"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(
`/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(
@@ -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" });
assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404);
assertEquals((await get(`/api/thumbnails/nope`)).status, 400);
assertEquals((await get(`/api/dumps/${text.id}/thumbnail`)).status, 404);
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 gif = new TextEncoder().encode("GIF89a-not-really");
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",
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.headers.get("content-type"), "image/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",
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.headers.get("content-type"), "image/png");
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",
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.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);

View File

@@ -6,10 +6,13 @@ import {
del,
get,
insertDump,
insertPlaylist,
PASSWORD,
patch,
post,
put,
test,
uniqueName,
} from "../helpers.ts";
import type {
AuthResponse,
@@ -28,6 +31,7 @@ import {
} from "../../lib/jwt.ts";
import { authenticateMedia } from "../../lib/auth.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import {
DEFAULT_SETTINGS,
@@ -42,10 +46,14 @@ function invite(inviterId: string, createdAt = new Date()): string {
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();
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);
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("passwordHash" in res.data);
}
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
assertEquals((await get(`/api/users/@no_one_here`)).status, 404);
// 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);
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 taken = await createUser();
const res = await patch<PublicUser>("/api/users/me", {
const res = await patch<SelfUser>("/api/me", {
auth: user.auth,
json: { description: "about me", email: "new@example.test" },
});
assertEquals(res.status, 200);
assertEquals(res.data.description, "about me");
assertFalse("email" in res.data);
assertEquals(
(await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email,
"new@example.test",
);
// A role, username or password in the body is ignored, not applied.
await patch("/api/users/me", {
assertEquals(res.data.email, "new@example.test");
assertFalse("passwordHash" in res.data);
// A role or password in the body is ignored, not applied.
const hash = () =>
(db.prepare(`SELECT password_hash FROM users WHERE id = ?;`).get(
user.id,
) as { password_hash: string }).password_hash;
const before = hash();
await patch("/api/me", {
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,
) as {
role: string;
username: string;
};
assertEquals(row, { role: "user", username: user.username });
) as { role: string };
assertEquals(row.role, "user");
assertEquals(hash(), before);
assertEquals(
(await patch("/api/users/me", {
(await patch("/api/me", {
auth: user.auth,
json: { email: taken.email },
})).status,
409,
);
assertEquals(
(await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } }))
(await patch("/api/me", { auth: user.auth, json: { email: "bad" } }))
.status,
400,
);
assertEquals(
(await patch("/api/users/me", {
(await patch("/api/me", {
auth: user.auth,
json: { description: "x".repeat(2001) },
})).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 mod = await createUser({ role: "moderator" });
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}` });
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[0], {
id: res.data[0].id,
username: "zed_0",
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.
await createUser({ username: "zedx9" });
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
),
["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 child = await createUser({
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",
});
const tree =
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data;
(await get<Invitee[]>(`/api/users/@${root.username}/invitees`)).data;
assertEquals(
tree.map((
n: { username: string; invitedById: string },
) => [n.username, n.invitedById]),
[[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);
});
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 other = await createUser();
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());
}
for (const sub of ["dumps", "votes"]) {
const path = `/api/users/${owner.username}/${sub}`;
for (const sub of ["dumps", "upvoted"]) {
const path = `/api/users/@${owner.username}/${sub}`;
const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.items.length;
assertEquals(await total(), 1, sub);
@@ -223,14 +233,14 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
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 ───────────────────────────────────────────────────────
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 ok = await post<AuthResponse>("/api/users/login", {
const ok = await post<AuthResponse>("/api/sessions", {
json: { username: user.username, password: PASSWORD },
});
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,
);
const wrong = await post("/api/users/login", {
const wrong = await post("/api/sessions", {
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" },
});
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(
(await post("/api/users/login", { json: { username: user.username } }))
(await post("/api/sessions", { json: { username: user.username } }))
.status,
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 statuses: number[] = [];
for (let i = 0; i < 11; i++) {
const username = i % 2 ? user.username.toUpperCase() : user.username;
statuses.push(
(await post("/api/users/login", {
(await post("/api/sessions", {
json: { username, password: "wrong" },
})).status,
);
@@ -274,18 +284,18 @@ test("POST /api/users/login: rate limited per account, case-insensitively", asyn
assertEquals(statuses[10], 429);
// Even the right password is held back now.
assertEquals(
(await post("/api/users/login", {
(await post("/api/sessions", {
json: { username: user.username, password: PASSWORD },
})).status,
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();
const inviter = await createUser();
const inviteToken = invite(inviter.id);
const res = await post<AuthResponse>("/api/users/register", {
const res = await post<AuthResponse>("/api/users", {
json: {
username: "newbie_reg",
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"]);
assert(mail.sent[0].html.includes("newbie_reg"));
const again = await post("/api/users/register", {
const again = await post("/api/users", {
json: {
username: "second_reg",
password: "longenough",
@@ -317,7 +327,7 @@ test("POST /api/users/register: an invite opens one account", async () => {
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 inviteToken = invite(inviter.id);
const base = {
@@ -335,11 +345,11 @@ test("POST /api/users/register: validation and unknown invites", async () => {
[{ ...base, inviteToken: 42 }, /Invalid request/],
];
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));
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" },
});
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 taken = await createUser();
const inviteToken = invite(inviter.id);
const byName = await post("/api/users/register", {
const byName = await post("/api/users", {
json: {
username: taken.username.toUpperCase(),
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);
const byEmail = await post("/api/users/register", {
const byEmail = await post("/api/users", {
json: {
username: "fresh_name",
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 inviteToken = invite(
inviter.id,
new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
);
const res = await post("/api/users/register", {
const res = await post("/api/users", {
json: {
username: "late_comer",
password: "longenough",
@@ -398,12 +408,12 @@ test("POST /api/users/register: an invite older than 7 days is refused", async (
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 inviteToken = invite(inviter.id);
const results = await Promise.all(
[1, 2, 3].map((i) =>
post("/api/users/register", {
post("/api/users", {
json: {
username: `racer_${i}`,
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 () => {
const user = await createUser();
const wrong = await post("/api/users/me/change-password", {
const wrong = await put("/api/me/password", {
auth: user.auth,
json: { currentPassword: "nope", newPassword: "brand-new-pass" },
});
assertEquals(wrong.status, 403);
const short = await post("/api/users/me/change-password", {
const short = await put("/api/me/password", {
auth: user.auth,
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
// second than the change.
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,
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
});
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);
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);
const login = await post("/api/users/login", {
const login = await post("/api/sessions", {
json: { username: user.username, password: "brand-new-pass" },
});
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 () => {
const user = await createUser();
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 },
});
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 () => {
using mail = captureEmails();
const user = await createUser();
await post("/api/users/request-password-reset", {
await post("/api/password-resets", {
json: { email: user.email },
});
await post("/api/users/request-password-reset", {
await post("/api/password-resets", {
json: { email: "nobody@example.test" },
});
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(
mail.sent[0].text.match(/token=([^\s)]+)/)![1],
);
const res = await post("/api/users/reset-password", {
json: { token, newPassword: "from-the-email" },
const res = await put(`/api/password-resets/${token}`, {
json: { newPassword: "from-the-email" },
});
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 (?, ?, ?);`,
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
const short = await post("/api/users/reset-password", {
json: { token, newPassword: "short" },
const short = await put(`/api/password-resets/${token}`, {
json: { newPassword: "short" },
});
assertEquals(short.status, 400);
const ok = await post("/api/users/reset-password", {
json: { token, newPassword: "reset-password-1" },
const ok = await put(`/api/password-resets/${token}`, {
json: { newPassword: "reset-password-1" },
});
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" },
});
assertEquals(login.status, 200);
const reuse = await post("/api/users/reset-password", {
json: { token, newPassword: "reset-password-2" },
const reuse = await put(`/api/password-resets/${token}`, {
json: { newPassword: "reset-password-2" },
});
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 unstored = await createPasswordResetToken(user.id);
assertEquals(
(await post("/api/users/reset-password", {
json: { token: unstored, newPassword: "whatever-123" },
(await put(`/api/password-resets/${unstored}`, {
json: { newPassword: "whatever-123" },
})).status,
404,
);
for (const token of ["garbage", user.token, user.mediaToken]) {
assertEquals(
(await post("/api/users/reset-password", {
json: { token, newPassword: "whatever-123" },
(await put(`/api/password-resets/${token}`, {
json: { newPassword: "whatever-123" },
})).status,
400,
);
}
assertEquals(
(await post("/api/users/reset-password", { json: { token: 1 } })).status,
(await put(`/api/password-resets/${unstored}`, { json: {} })).status,
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 res = await get<MediaTokenResponse>("/api/users/me/media-token", {
const res = await get<MediaTokenResponse>("/api/me/media-token", {
auth: user.auth,
});
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 () => {
const ann = 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,
});
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,
json: { style: "kanagawa", digestWeekly: false },
});
@@ -565,7 +575,7 @@ test("settings: defaults until changed, a patch keeps the rest, each user their
style: "kanagawa",
digestWeekly: false,
});
const scheme = await patch<UserSettings>("/api/users/me/settings", {
const scheme = await patch<UserSettings>("/api/me/settings", {
auth: ann.auth,
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.digestWeekly, false);
const bobs = await get<UserSettings>("/api/users/me/settings", {
const bobs = await get<UserSettings>("/api/me/settings", {
auth: bob.auth,
});
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 () => {
const ann = await createUser();
assertEquals((await get("/api/users/me/settings")).status, 401);
assertEquals((await get("/api/me/settings")).status, 401);
for (
const json of [
{ 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));
}
// 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,
json: { 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 c = await connect(user);
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,
json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
});
@@ -642,14 +642,14 @@ wsTest(
const pl = insertPlaylist({ userId: owner.id });
const c = await connect();
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,
form: formWithFile(cover),
});
// Fetch the image the moment the socket says it changed, as a client would.
const msg = await c.next("playlist_updated");
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.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,
updateSettings,
} from "../../services/settings-service.ts";
import { createUnsubscribeToken } from "../../lib/jwt.ts";
const DAY = 86_400_000;
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);
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 () => {
@@ -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 () => {
const user = await createUser();
assertEquals((await post("/api/users/unsubscribe?token=nope")).status, 400);
assertEquals(
(await post(`/api/users/unsubscribe?token=${user.token}`)).status,
400,
);
assertEquals((await post("/api/users/unsubscribe")).status, 400);
for (const path of ["/api/unsubscribe", "/api/users/unsubscribe"]) {
assertEquals((await post(`${path}?token=nope`)).status, 400, path);
assertEquals(
(await post(`${path}?token=${user.token}`)).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;
const o = obj as Record<string, unknown>;
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 (typeof o.title !== "string") return false;
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;
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 {
if (!obj || typeof obj !== "object") return false;
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 (typeof o.title !== "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
* trusts it.
* Checks the body of `PATCH /api/me/settings` before `routes/me.ts` trusts
* it.
*
* @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
*/
@@ -74,7 +75,7 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
/**
* 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.
*/
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {