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

@@ -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`.