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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user