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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user