v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 13:10:07 +00:00
parent 134d1a0b97
commit c3594e4c27
39 changed files with 445 additions and 360 deletions

View File

@@ -9,6 +9,7 @@
import { db, transaction } from "../db/connection.ts";
import { APIException } from "../lib/api-exception.ts";
import { attachmentIdsIn } from "../../shared/attachment.ts";
/**
* Records attachment `id` once `POST /api/attachments` has written its file.
@@ -108,9 +109,6 @@ function resourceOwner(
}
}
const ATTACHMENT_RE =
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
/**
* Binds the attachments whose URLs appear in `text` to the resource that saved
* it, so the startup purge keeps them as long as that resource exists, and
@@ -124,7 +122,7 @@ export function linkAttachments(
resourceType: AttachmentResourceType,
resourceId: string,
): void {
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
const ids = attachmentIdsIn(text);
if (ids.length === 0) return;
const update = db.prepare(
"UPDATE attachments SET resource_type = ?, resource_id = ? WHERE id = ? AND resource_id IS NULL",

View File

@@ -22,27 +22,17 @@ import {
} from "../model/dump.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug } from "../lib/reserved-slugs.ts";
import { SLUG_RE } from "../../shared/slug.ts";
import { validateSlug } from "../lib/slugify.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { parseRows } from "../model/rows.ts";
/**
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
* with a message for each case.
* Throws a 400 unless `slug` is a valid category slug: a slug within a
* category's length, and none of the top-level paths other pages hold
* (reserved-slugs.ts), since a category's feed lives at the URL root.
*/
function validateSlug(slug: string): void {
if (!SLUG_RE.test(slug)) {
throw new APIException(
400,
"Slug must be lowercase letters, numbers and hyphens",
);
}
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
throw new APIException(
400,
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
);
}
function validateCategorySlug(slug: string): void {
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
if (isReservedSlug(slug)) {
throw new APIException(400, `Slug "${slug}" is reserved`);
}
@@ -133,7 +123,7 @@ export function listCategories(): Category[] {
* either is taken.
*/
export function createCategory(req: CreateCategoryRequest): Category {
validateSlug(req.slug);
validateCategorySlug(req.slug);
const name = requireName(req.name);
const id = crypto.randomUUID();
@@ -177,7 +167,7 @@ export function updateCategory(
const newSlug = req.slug ?? existing.slug;
if (req.slug !== undefined && req.slug !== existing.slug) {
validateSlug(req.slug);
validateCategorySlug(req.slug);
}
const newName = req.name !== undefined
? requireName(req.name)

View File

@@ -24,7 +24,9 @@ import {
PERIOD_MS,
} from "../../shared/settings.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type { Dump } from "../../shared/dump.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import { siteHost } from "../../shared/url.ts";
import { markdownToPlainText } from "../../shared/markdown.ts";
import {
DUMP_SELECT_COLUMNS_ALIASED,
dumpRowToApi,
@@ -61,7 +63,6 @@ import { lookOf } from "../mail/layout.ts";
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";
import { communityStatsOf } from "./community-service.ts";
@@ -93,12 +94,7 @@ interface Window {
/** Flattens Markdown into a short line of text for a quote. */
function quoteOf(markdown: string | undefined | null): string | undefined {
if (!markdown) return undefined;
const flat = markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[`*_>#~]/g, "")
.replace(/\s+/g, " ")
.trim();
const flat = markdownToPlainText(markdown, "");
if (!flat) return undefined;
return flat.length > QUOTE_MAX
? `${flat.slice(0, QUOTE_MAX - 1).trimEnd()}…`
@@ -131,14 +127,8 @@ function thumbnailOf(dump: Dump): string | null {
}
function toDigestDump(dump: Dump, readerId: string): DigestDump {
let domain: string | null = null;
if (dump.kind === "url" && dump.url) {
try {
domain = new URL(dump.url).hostname.replace(/^www\./, "");
} catch {
domain = null;
}
}
const domain = (dump.kind === "url" && dump.url && siteHost(dump.url)) ||
null;
return {
title: dump.title,
url: `${FRONTEND_URL}${dumpPath(dump)}`,

View File

@@ -17,6 +17,7 @@ import type {
DumpUrlMatch,
UpdateDumpRequest,
} from "../../shared/dump.ts";
import { siteHost } from "../../shared/url.ts";
import { db, transaction } from "../db/connection.ts";
import {
DUMP_SELECT_COLUMNS as SELECT_COLS,
@@ -83,15 +84,6 @@ function assertFileDumpText(title?: string, comment?: string): void {
}
}
/** Returns a link's host name, the title it gets when its page has none. */
function titleFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/**
* Notifies the followers of a dump that went public, `before` being private
* and `after` public: the poster's, as if it were just posted, and those of
@@ -129,7 +121,7 @@ export async function createUrlDump(
// A title typed on the create form wins over the one scraped from the page:
// the poster has seen the preview and is correcting it.
const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url);
(siteHost(request.url) ?? request.url);
const isPrivate = request.isPrivate ?? false;
const categoryIds = [...new Set(request.categoryIds ?? [])];
@@ -244,20 +236,6 @@ export async function createFileDump(
return dump;
}
/**
* Returns a file dump's version, which the app's file and thumbnail URLs carry
* as `?v=`. It changes whenever the file may have, so the file and thumbnail
* routes cache a URL naming the current one for good. The frontend's
* `dumpFileVersion` must compute the same.
*/
export function dumpFileVersion(
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
): string {
return `${dump.fileSize ?? 0}-${
(dump.updatedAt ?? dump.createdAt).getTime()
}`;
}
/**
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
* upload, once the upload is replaced or deleted. The route makes them again
@@ -542,7 +520,7 @@ export async function updateDump(
if (newUrl !== dump.url) {
richContent = await fetchRichContent(newUrl);
title = richContent?.title ?? titleFromUrl(newUrl);
title = richContent?.title ?? siteHost(newUrl) ?? newUrl;
}
if (request.title?.trim()) {
@@ -819,7 +797,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
);
}
const title = richContent?.title ?? titleFromUrl(dump.url);
const title = richContent?.title ?? siteHost(dump.url) ?? dump.url;
const updatedDump: Dump = { ...dump, title, richContent };
const row = dumpApiToRow(updatedDump);

View File

@@ -8,6 +8,8 @@
*/
import type { RichContent } from "../../shared/dump.ts";
import { siteHost } from "../../shared/url.ts";
import { TtlCache } from "../lib/ttl-cache.ts";
import type { RichContentProvider } from "../remote/providers/provider.ts";
import { youtubeProvider } from "../remote/providers/youtube.ts";
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
@@ -47,19 +49,12 @@ export interface FetchRichContentResult {
content?: RichContent;
}
/** How long a fetched preview is reused. */
const RECENT_TTL_MS = 5 * 60 * 1000;
/** How many previews are kept. The oldest written goes first. */
const RECENT_MAX = 200;
/**
* Makes posting a link fast after the create form previewed it, by keeping the
* previews fetched in the last few minutes, by URL. Without it, the second
* previews fetched in the last five minutes, by URL. Without it, the second
* fetch of the page was the slow part of posting. Only successes are kept.
*/
const recent = new Map<
string,
{ at: number; result: Promise<FetchRichContentResult> }
>();
const recent = new TtlCache<Promise<FetchRichContentResult>>(5 * 60_000, 200);
/**
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
@@ -78,20 +73,13 @@ export function tryFetchRichContent(
url: string,
{ fresh = false }: { fresh?: boolean } = {},
): Promise<FetchRichContentResult> {
const hit = recent.get(url);
if (!fresh && hit && Date.now() - hit.at < RECENT_TTL_MS) return hit.result;
recent.delete(url);
const hit = fresh ? undefined : recent.get(url);
if (hit) return hit;
// Stored while in flight too, so a preview and a post racing share a fetch.
const result = fetchRichContentUncached(url);
if (recent.size >= RECENT_MAX) {
const oldest = recent.keys().next().value;
if (oldest !== undefined) recent.delete(oldest);
}
const entry = { at: Date.now(), result };
recent.set(url, entry);
const result = recent.set(url, fetchRichContentUncached(url));
result.then(({ ok }) => {
if (!ok && recent.get(url) === entry) recent.delete(url);
if (!ok) recent.delete(url, result);
});
return result;
}
@@ -108,18 +96,10 @@ async function fetchRichContentUncached(
return { ok: true, content: await provider.fetch(url) };
} catch (err) {
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
try {
return {
ok: false,
content: {
type: "generic",
url,
siteName: new URL(url).hostname.replace(/^www\./, ""),
},
};
} catch {
return { ok: false };
}
const siteName = siteHost(url);
return siteName
? { ok: false, content: { type: "generic", url, siteName } }
: { ok: false };
}
}

View File

@@ -120,7 +120,11 @@ function sendRaw(socket: WebSocket, payload: string): void {
if (socket.readyState === WebSocket.OPEN) socket.send(payload);
}
function send(socket: WebSocket, data: ServerToClientMessage): void {
/**
* Sends `data` to one socket unless it is closing. The WebSocket route answers
* a client's own message with it: a pong, a vote's ack, an error.
*/
export function send(socket: WebSocket, data: ServerToClientMessage): void {
sendRaw(socket, JSON.stringify(data));
}