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

@@ -13,6 +13,8 @@
* @module
*/
import { siteHostOf } from "../../shared/url.ts";
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
@@ -52,7 +54,7 @@ const YOUTUBE_HOSTS = new Set([
* uses it too, so both agree on what a YouTube link is.
*/
export function youtubeHost(u: URL): string | null {
const host = u.hostname.toLowerCase().replace(/^www\./, "");
const host = siteHostOf(u);
return YOUTUBE_HOSTS.has(host) ? host : null;
}
@@ -90,7 +92,7 @@ export function canonicalizeUrl(raw: string): string | null {
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const host = u.hostname.toLowerCase().replace(/^www\./, "");
const host = siteHostOf(u);
if (!host) return null;
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);

91
api/lib/css-color.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Reads the CSS colors the server meets: a dumped page's `theme-color`, which
* the generic link preview keeps as its tint (`normalizeCssColor`), and the
* theme stylesheets' tokens, which the emails resolve (`theme-tokens.ts`).
*
* @module
*/
/** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* The named colors sites actually put in `theme-color`, and `transparent`,
* which the themes write; the rest of CSS's names aren't worth carrying.
*/
const NAMED: Record<string, string> = {
transparent: "#00000000",
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
/** Reads a channel of `rgb()`, a number or a percentage of 255. */
function channel(text: string): number {
const n = parseFloat(text);
const value = text.endsWith("%") ? (n / 100) * 255 : n;
return Math.max(0, Math.min(255, value));
}
/** Reads the alpha of `rgba()`, a number or a percentage of 1. */
function alpha(text: string): number {
const n = parseFloat(text);
return Math.max(0, Math.min(1, text.endsWith("%") ? n / 100 : n));
}
/**
* Returns the color `text` writes as a hex (3, 4, 6 or 8 digits), an `rgb()`
* or `rgba()` of numbers or percentages, or one of the common names; undefined
* for any other form. Channels out of range are clamped.
*/
export function parseCssColor(text: string): Rgba | undefined {
const value = text.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(NAMED[value] ?? value)?.[1];
if (hex) {
if (hex.length === 5 || hex.length === 7) return undefined;
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
const byte = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16);
return {
r: byte(0),
g: byte(1),
b: byte(2),
a: full.length === 8 ? byte(3) / 255 : 1,
};
}
const args = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (!args) return undefined;
const parts = args.split(/[\s,/]+/).filter(Boolean);
if (parts.length !== 3 && parts.length !== 4) return undefined;
const [r, g, b] = parts.slice(0, 3).map(channel);
const a = parts[3] === undefined ? 1 : alpha(parts[3]);
return [r, g, b, a].some(Number.isNaN) ? undefined : { r, g, b, a };
}
/** Writes `color` as a lowercase `#rrggbb`, its alpha dropped. */
export function hexOf({ r, g, b }: Rgba): string {
return `#${
[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")
}`;
}

View File

@@ -72,14 +72,15 @@ export function uniqueSlug(
/**
* Throws a 400 unless `slug` is a valid slug, lowercase words of letters and
* digits joined by hyphens, at most `SLUG_MAX` long. Dump and playlist slugs
* reserve no word: nothing else a user names lives beside them.
* digits joined by hyphens, at most `max` long. slug-service checks a dump's
* or a playlist's with it, and category-service a category's, with that
* kind's own limit.
*/
export function validateSlug(slug: string): void {
if (!isValidSlug(slug)) {
export function validateSlug(slug: string, max: number = SLUG_MAX): void {
if (!isValidSlug(slug, max)) {
throw new APIException(
400,
`Slug must be lowercase letters, numbers and hyphens, at most ${SLUG_MAX} characters`,
`Slug must be lowercase letters, numbers and hyphens, at most ${max} characters`,
);
}
}

60
api/lib/ttl-cache.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Keeps what the server fetched from another site for a while, by key, so a
* page or a feed asking again doesn't refetch it: Bandcamp tracklists, YouTube
* playlists, and link previews. Each holder sets how long an entry lives and
* how many are kept; past that count, the oldest written goes first.
*
* @module
*/
/**
* A map whose entries expire `ttlMs` after they are written, holding
* `maxEntries` at most.
*
* @example
* const playlists = new TtlCache<YouTubePlaylist>(6 * 3_600_000, 200);
* const hit = playlists.get(listId) ?? playlists.set(listId, await fetch());
*/
export class TtlCache<V> {
#entries = new Map<string, { value: V; at: number }>();
constructor(readonly ttlMs: number, readonly maxEntries: number) {}
/** Returns the value under `key` unless it is missing or expired. */
get(key: string): V | undefined {
const hit = this.#entries.get(key);
if (!hit) return undefined;
if (Date.now() - hit.at >= this.ttlMs) {
this.#entries.delete(key);
return undefined;
}
return hit.value;
}
/** Stores `value` under `key` from now on, and returns it. */
set(key: string, value: V): V {
this.#entries.delete(key);
// A Map keeps insertion order, so the first key is the oldest write.
if (this.#entries.size >= this.maxEntries) {
const oldest = this.#entries.keys().next().value;
if (oldest !== undefined) this.#entries.delete(oldest);
}
this.#entries.set(key, { value, at: Date.now() });
return value;
}
/**
* Removes `key`, or only when `value` is still what it holds, so a late
* failure can't remove the entry that replaced its own.
*/
delete(key: string, value?: V): void {
if (value === undefined || this.#entries.get(key)?.value === value) {
this.#entries.delete(key);
}
}
/** Empties the cache, for the tests' reset between cases. */
clear(): void {
this.#entries.clear();
}
}

View File

@@ -16,18 +16,11 @@
*/
import { BASE_STYLE, type StyleName } from "../../shared/theme.ts";
import { hexOf, parseCssColor, type Rgba } from "../lib/css-color.ts";
/** A scheme a token is resolved for. "auto" is drawn from both. */
export type Scheme = "light" | "dark";
/** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Returned by {@link readTokens}: the tokens of one style in one scheme, read
* as the kind of value each is.
@@ -134,33 +127,16 @@ function replaceCalls(
/** Parses a CSS color in the forms the themes write, or throws. */
function parseColor(text: string): Rgba {
const value = text.trim().toLowerCase();
if (value === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
const hex = value.match(/^#([0-9a-f]{3,8})$/)?.[1];
if (hex && hex.length !== 5 && hex.length !== 7) {
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
const channel = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16);
return {
r: channel(0),
g: channel(1),
b: channel(2),
a: full.length === 8 ? channel(3) / 255 : 1,
};
}
const rgb = value.match(/^rgba?\((.*)\)$/)?.[1];
if (rgb) {
const [r, g, b, a = "1"] = rgb.split(/[\s,/]+/).filter(Boolean);
const alpha = a.endsWith("%") ? parseFloat(a) / 100 : parseFloat(a);
return { r: +r, g: +g, b: +b, a: alpha };
}
throw new Error(`Unreadable color: ${text}`);
const color = parseCssColor(text);
if (!color) throw new Error(`Unreadable color: ${text}`);
return color;
}
/** Writes `color` as `#rrggbb`, or as `rgba()` when it is translucent. */
function formatColor({ r, g, b, a }: Rgba): string {
const [R, G, B] = [r, g, b].map((c) => Math.round(c));
if (a < 1) return `rgba(${R},${G},${B},${+a.toFixed(3)})`;
return `#${[R, G, B].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
function formatColor(color: Rgba): string {
if (color.a >= 1) return hexOf(color);
const [r, g, b] = [color.r, color.g, color.b].map(Math.round);
return `rgba(${r},${g},${b},${+color.a.toFixed(3)})`;
}
/** Returns `top` laid over `under`, both straight (not premultiplied) alpha. */

View File

@@ -9,19 +9,16 @@
*/
import { APIException } from "../lib/api-exception.ts";
import { TtlCache } from "../lib/ttl-cache.ts";
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts";
/**
* Half the signatures' life, so a cached URL still has at least 12 hours left
* when it is handed out.
* Lives half the signatures' life, so a cached URL still has at least 12
* hours left when it is handed out.
*/
const TTL_MS = 12 * 60 * 60 * 1000;
/** The oldest write goes first. */
const MAX_ENTRIES = 200;
const cache = new Map<string, Tralbum>();
const cache = new TtlCache<Tralbum>(12 * 3_600_000, 200);
/**
* Returns `url` without its query, fragment and trailing slashes, and with its
@@ -38,25 +35,6 @@ function cacheKey(url: string): string {
}
}
function readCache(key: string): Tralbum | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.resolvedAt > TTL_MS) {
cache.delete(key);
return null;
}
return hit;
}
function writeCache(key: string, value: Tralbum): void {
// Map preserves insertion order, so the first key is the oldest write.
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, value);
}
/**
* Returns the tracklist of the Bandcamp page `pageUrl`, from the cache when it
* was resolved in the last 12 hours. `force` skips the cache: the player sets
@@ -87,7 +65,7 @@ export async function resolveBandcamp(
const key = cacheKey(pageUrl);
if (!force) {
const hit = readCache(key);
const hit = cache.get(key);
if (hit) return hit;
}
@@ -115,6 +93,5 @@ export async function resolveBandcamp(
throw new APIException(404, "No streamable tracks on this page");
}
writeCache(key, parsed);
return parsed;
return cache.set(key, parsed);
}

View File

@@ -7,7 +7,11 @@
* @module
*/
import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts";
import {
bareHostname,
isPrivateHostname,
resolvePublicAddress,
} from "./public-address.ts";
import { isSelfUrl } from "../lib/self-url.ts";
const FETCH_HEADERS = {
@@ -43,7 +47,7 @@ async function fetchViaCurl(
"--proto",
"=http,https",
"--resolve",
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
`${bareHostname(url)}:${port}:${pinned}`,
"--max-time",
String(Math.ceil(timeoutMs / 1000)),
// curl fails on a larger file rather than stopping at a cap, so this is

View File

@@ -7,6 +7,8 @@
* @module
*/
import { hexOf, parseCssColor } from "../lib/css-color.ts";
/**
* Decodes the five XML entities and numeric ones in `str`. `&amp;` goes first,
* so `&amp;quot;` decodes twice; `bandcamp-tralbum.ts`, which decodes JSON,
@@ -118,74 +120,15 @@ export function extractMaskIconColor(html: string): string | undefined {
return undefined;
}
/**
* The named colors sites actually put in `theme-color`; the rest of CSS's names
* aren't worth carrying.
*/
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
/**
* Returns `raw` as a lowercase `#rrggbb`, from a hex, `rgb()`, `rgba()` or
* common named color; undefined for any other form. The generic provider
* stores it as the preview's `accentColor`, a tint over the app's own surface,
* so alpha is dropped.
* so alpha is dropped, and a fully transparent one is no color at all.
*/
export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
const named = NAMED_COLORS[value];
if (named) return named;
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
const [r, g, b] = [...hex.slice(0, 3)];
return `#${r}${r}${g}${g}${b}${b}`;
}
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
return undefined;
}
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (rgb) {
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
if (parts.length !== 3) return undefined;
const bytes = parts.map((p) => {
const n = parseFloat(p);
if (!Number.isFinite(n)) return NaN;
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
});
if (bytes.some(Number.isNaN)) return undefined;
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
return undefined;
const color = raw ? parseCssColor(raw) : undefined;
return color && color.a > 0 ? hexOf(color) : undefined;
}
/**

View File

@@ -7,6 +7,7 @@
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { siteHost } from "../../../shared/url.ts";
import {
extractBestIcon,
extractFirstContentImage,
@@ -66,7 +67,7 @@ export const genericProvider: RichContentProvider = {
extractPageTitle(html);
const siteName = extractOgTag(html, "site_name") ??
new URL(url).hostname.replace(/^www\./, "");
siteHost(url);
const description = extractOgTag(html, "description") ??
extractMetaName(html, "twitter:description") ??

View File

@@ -11,12 +11,13 @@ import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts";
import { siteHostOf } from "../../../shared/url.ts";
/** Returns the id of the video `url` points at, in any shape YouTube uses. */
function extractVideoId(url: string): string | null {
try {
const u = new URL(url);
const host = u.hostname.toLowerCase().replace(/^www\./, "");
const host = siteHostOf(u);
return youtubeVideoId(host, u.pathname, u.searchParams);
} catch {
return null;

View File

@@ -100,9 +100,10 @@ function isIpLiteral(host: string): boolean {
/**
* Returns `url`'s hostname without the brackets `URL.hostname` keeps around
* an IPv6 literal.
* an IPv6 literal: the address checks here read it, and `fetch.ts` hands it
* to curl's `--resolve`.
*/
function bareHostname(url: URL): string {
export function bareHostname(url: URL): string {
return url.hostname.replace(/^\[(.*)\]$/, "$1");
}

View File

@@ -13,6 +13,7 @@
*/
import { APIException } from "../lib/api-exception.ts";
import { TtlCache } from "../lib/ttl-cache.ts";
import { readTextCapped } from "./fetch.ts";
import type {
YouTubePlaylist,
@@ -20,11 +21,7 @@ import type {
} from "../../shared/player.ts";
/** Titles and lengths rarely change, and a stale one only mislabels a row. */
const TTL_MS = 6 * 60 * 60 * 1000;
/** The oldest write goes first. */
const MAX_ENTRIES = 200;
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
const cache = new TtlCache<YouTubePlaylist>(6 * 3_600_000, 200);
/**
* Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else.
@@ -167,8 +164,7 @@ export async function resolveYouTubePlaylist(
}
const hit = cache.get(listId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
if (hit) cache.delete(listId);
if (hit) return hit;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
@@ -198,10 +194,5 @@ export async function resolveYouTubePlaylist(
throw new APIException(404, "No tracks on this playlist");
}
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(listId, { value: parsed, at: Date.now() });
return parsed;
return cache.set(listId, parsed);
}

View File

@@ -20,7 +20,7 @@ import {
import { authenticateMedia } from "../lib/auth.ts";
import { type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { AttachmentResponse } from "../../shared/dump.ts";
import type { AttachmentResponse } from "../../shared/attachment.ts";
import { serveUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { ATTACHMENTS_DIR } from "../config.ts";

View File

@@ -16,18 +16,15 @@
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import { DUMPS_DIR } from "../config.ts";
import { UUID_RE } from "../../shared/validation.ts";
import { authenticateMedia } from "../lib/auth.ts";
import { can } from "../lib/permissions.ts";
import { serveFile } from "../lib/serve-file.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { parseCategoryIds } from "../validators/dump.ts";
import {
dumpFileVersion,
getDump,
replaceFileDump,
} from "../services/dump-service.ts";
import { getDump, replaceFileDump } from "../services/dump-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -71,7 +68,7 @@ router.get("/:dumpId/file", async (ctx) => {
const { dumpId } = ctx.params;
// The id names a file on disk: nothing but an id's characters gets through.
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
if (!UUID_RE.test(dumpId)) {
throw new APIException(400, "Invalid dump ID");
}

View File

@@ -22,9 +22,8 @@
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import {
dumpFileVersion,
getDump,
getDumpThumbnailInfo,
removeDumpThumbnail,
@@ -37,6 +36,7 @@ import {
validateImageUpload,
} from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { UUID_RE } from "../../shared/validation.ts";
import { authenticateMedia } from "../lib/auth.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
@@ -190,7 +190,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
const { dumpId } = ctx.params;
// The id names files on disk: nothing but an id's characters gets through.
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
if (!UUID_RE.test(dumpId)) {
throw new APIException(400, "Invalid dump ID");
}

View File

@@ -27,6 +27,7 @@ import {
isUserChatOpen,
type RateLimit,
register,
send,
sendToUser,
setClientTyping,
takeToken,
@@ -59,10 +60,7 @@ import {
} from "../services/comment-like-service.ts";
import { getUserById } from "../services/user-service.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
ClientToServerMessage,
ServerToClientMessage,
} from "../../shared/ws.ts";
import type { ClientToServerMessage } from "../../shared/ws.ts";
import type { User } from "../model/user.ts";
/**
@@ -125,7 +123,7 @@ router.get("/ws", async (ctx) => {
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
const chatRead = user ? getChatReadState(user.id) : undefined;
socket.send(JSON.stringify({
send(socket, {
type: "welcome",
users: getOnlineUsers(),
myVotes,
@@ -133,7 +131,7 @@ router.get("/ws", async (ctx) => {
unreadNotificationCount,
chatReadAt: chatRead?.readAt,
unreadChatCount: chatRead?.unread ?? 0,
}));
});
} catch (err) {
console.error("[ws] welcome send failed:", err);
}
@@ -223,11 +221,6 @@ function handleMessage(client: WsClient, data: unknown): void {
}
}
/** Sends `data` unless the socket is closing, where `send` would throw. */
function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
}
/**
* Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and
* broadcasts `votes_update`. A failure goes back as an `error` naming the dump

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));
}

View File

@@ -0,0 +1,37 @@
import { assertEquals } from "@std/assert";
import { FakeTime } from "@std/testing/time";
import { TtlCache } from "../../lib/ttl-cache.ts";
Deno.test("TtlCache: an entry expires ttlMs after it is written", () => {
using time = new FakeTime();
const cache = new TtlCache<string>(1000, 10);
cache.set("a", "x");
time.tick(999);
assertEquals(cache.get("a"), "x");
time.tick(1);
assertEquals(cache.get("a"), undefined);
});
Deno.test("TtlCache: past maxEntries, the oldest write goes first", () => {
const cache = new TtlCache<number>(1000, 2);
cache.set("a", 1);
cache.set("b", 2);
// Rewriting "a" makes it the newest, so "b" is the oldest now.
cache.set("a", 3);
cache.set("c", 4);
assertEquals(cache.get("a"), 3);
assertEquals(cache.get("b"), undefined);
assertEquals(cache.get("c"), 4);
});
Deno.test("TtlCache: delete with a value spares a newer entry", () => {
const cache = new TtlCache<object>(1000, 10);
const first = {};
const second = {};
cache.set("k", first);
cache.set("k", second);
cache.delete("k", first);
assertEquals(cache.get("k"), second);
cache.delete("k", second);
assertEquals(cache.get("k"), undefined);
});

View File

@@ -78,6 +78,7 @@ Deno.test("normalizeCssColor", () => {
assertEquals(normalizeCssColor("#12345"), undefined);
assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined);
assertEquals(normalizeCssColor("rgb(1,2)"), undefined);
assertEquals(normalizeCssColor("transparent"), undefined);
assertEquals(normalizeCssColor(undefined), undefined);
});

View File

@@ -10,7 +10,7 @@ import {
post,
test,
} from "../helpers.ts";
import type { AttachmentResponse } from "../../../shared/dump.ts";
import type { AttachmentResponse } from "../../../shared/attachment.ts";
import type { Comment } from "../../../shared/comment.ts";
import { assertEquals } from "@std/assert";
import { runStartupTasks } from "../../db/startup.ts";

37
shared/attachment.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Says where an image pasted into a text lives, and how to find it again in
* the text. The editor uploads the image and writes a link to it into the
* Markdown; the server binds the attachments a saved text links to that text,
* and `Markdown` gives a private one the viewer's media token.
*
* @module
*/
import { UUID_RE } from "./validation.ts";
/**
* Returned by `POST /api/attachments` to the text editor, which inserts the
* image into the body as a link to {@link attachmentPath}.
*/
export interface AttachmentResponse {
id: string;
}
/** Returns the API path of attachment `id`, to which the API's base is added. */
export function attachmentPath(id: string): string {
return `/api/attachments/${id}`;
}
const ID = UUID_RE.source.slice(1, -1);
const ATTACHMENT_LINK_RE = new RegExp(`/api/attachments/(${ID})`, "g");
const ATTACHMENT_URL_RE = new RegExp(`/api/attachments/${ID}$`);
/** Returns the ids of the attachments `text` links to, in order. */
export function attachmentIdsIn(text: string): string[] {
return [...text.matchAll(ATTACHMENT_LINK_RE)].map((m) => m[1]);
}
/** Checks whether `url` is an attachment's, with no query yet. */
export function isAttachmentUrl(url: string): boolean {
return ATTACHMENT_URL_RE.test(url);
}

View File

@@ -142,9 +142,15 @@ export interface UpdateDumpRequest {
}
/**
* Returned by `POST /api/attachments` to the text editor, which inserts the
* image into the body as a link to `GET /api/attachments/:id`.
* Returns a file dump's version, which the file and thumbnail URLs carry as
* `?v=`: the frontend's `dumpFileUrl` and `dumpThumbnailUrl`, and the digest's
* thumbnails. It changes whenever the file may have, so the file and thumbnail
* routes cache a URL naming the current one for good and revalidate any other.
* Without its dates, a dump gets a version no route serves as current.
*/
export interface AttachmentResponse {
id: string;
export function dumpFileVersion(
dump: { fileSize?: number; createdAt?: Date; updatedAt?: Date },
): string {
const at = dump.updatedAt ?? dump.createdAt;
return `${dump.fileSize ?? 0}-${at ? at.getTime() : 0}`;
}

28
shared/markdown.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Flattens the Markdown users write into one line of plain text, where a
* quote of it can't render markup: the chat's reply previews and the digest
* email's quotes of a comment or a note.
*
* @module
*/
/**
* Returns the Markdown `md` as one line of plain text. Images become their alt
* text, or `imageLabel` without one, and links their text. It also takes a
* body cut short, possibly inside a link, as the server cuts a chat message's
* `replyToBody`: the unfinished link then shows as its text, without half a
* URL.
*/
export function markdownToPlainText(md: string, imageLabel: string): string {
return md
// An image or link cut short at the end loses its unfinished target.
.replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel)
.replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2")
.replace(/!\[[^\]]*$/, "")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt: string) => alt || imageLabel)
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
.replace(/```\w*|\*\*|__|~~|[*`]/g, "")
.replace(/\s+/g, " ")
.trim();
}

View File

@@ -0,0 +1,35 @@
import { assertEquals } from "@std/assert";
import { markdownToPlainText } from "../markdown.ts";
Deno.test("markdownToPlainText: links become their text, images their alt or a label", () => {
assertEquals(
markdownToPlainText("see [this](https://x.y) now", "img"),
"see this now",
);
assertEquals(markdownToPlainText("![cat](a.png)", "Image"), "cat");
assertEquals(markdownToPlainText("![](a.png)", "Image"), "Image");
});
Deno.test("markdownToPlainText: markup characters and block markers go", () => {
assertEquals(
markdownToPlainText("# Title\n> quote\n- **bold** `code`", ""),
"Title quote bold code",
);
});
Deno.test("markdownToPlainText: a body cut off mid-link doesn't show a half URL", () => {
assertEquals(
markdownToPlainText("look [here](https://exa", "img"),
"look here",
);
assertEquals(
markdownToPlainText("pic ![alt](https://exa", "Image"),
"pic alt",
);
assertEquals(
markdownToPlainText("pic ![](https://exa", "Image"),
"pic Image",
);
assertEquals(markdownToPlainText("pic ![unfinished", "Image"), "pic");
});
Deno.test("markdownToPlainText: whitespace collapses to one line", () => {
assertEquals(markdownToPlainText("a\n\n b\tc", ""), "a b c");
});

28
shared/url.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Names the site a link points at, as the app shows it: a link dump's title
* when its page has none, a preview's site name, the digest's domain line and
* the tint of a card without artwork. The server's link checks key on it too
* (`canonical-url.ts`), so a link reads as the same site everywhere.
*
* @module
*/
/**
* Returns `url`'s host without a leading `www.`; `URL` has already lowered
* its case. `www.youtube.com` and `youtube.com` give `youtube.com`.
*/
export function siteHostOf(url: URL): string {
return url.hostname.replace(/^www\./, "");
}
/**
* Returns {@link siteHostOf} the URL `raw`, or undefined when `raw` isn't
* one.
*/
export function siteHost(raw: string): string | undefined {
try {
return siteHostOf(new URL(raw));
} catch {
return undefined;
}
}

View File

@@ -46,6 +46,14 @@ export const RENAME_LIMITS = {
SLUG: { count: 3, windowMs: 24 * 3_600_000 },
} as const;
/**
* Matches an id the server makes with `crypto.randomUUID()`. A route checks
* one before it names a file on disk, and shared/attachment.ts finds the
* attachments a text links by it.
*/
export const UUID_RE: RegExp =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* Matches an email address well enough to catch a typo; only the reset email
* arriving proves it.

View File

@@ -12,6 +12,7 @@ import remarkGfm from "remark-gfm";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { userPath } from "../../../shared/paths.ts";
import { EVERYONE } from "../../../shared/mention.ts";
import { isAttachmentUrl } from "../../../shared/attachment.ts";
/** Passed to {@link Markdown} by any component that shows a user's text. */
export interface MarkdownProps {
@@ -154,8 +155,6 @@ function internalPath(href: string | undefined): string | null {
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
const ATTACHMENT_PATH_RE = /\/api\/attachments\/[0-9a-f-]{36}$/;
/**
* Renders an image of the text. An attachment gets the viewer's media token,
* which the server asks for when the text linking it is private; any other
@@ -166,7 +165,7 @@ function MarkdownImage(
): React.JSX.Element {
const { mediaToken } = useAuth();
let url = typeof src === "string" ? src : undefined;
if (url && mediaToken && ATTACHMENT_PATH_RE.test(url)) {
if (url && mediaToken && isAttachmentUrl(url)) {
url = `${url}?token=${encodeURIComponent(mediaToken)}`;
}
return <img src={url} alt={alt ?? ""} loading="lazy" decoding="async" />;

View File

@@ -27,6 +27,7 @@ import { useEmojiTrigger } from "../../hooks/editor/useEmojiTrigger.ts";
import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { API_URL } from "../../config/api.ts";
import { attachmentPath } from "../../../shared/attachment.ts";
/**
* Exposed through the ref of {@link TextEditor}, so that the component that
@@ -342,7 +343,7 @@ export const TextEditor: React.ForwardRefExoticComponent<
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
images.push(`![](${API_URL}/api/attachments/${json.data.id})`);
images.push(`![](${API_URL}${attachmentPath(json.data.id)})`);
} catch {
setNotice(
files.length > 1

View File

@@ -4,11 +4,8 @@ import { userEvent } from "@testing-library/user-event";
import { FilePreview } from "../../../components/dump/FilePreview.tsx";
import { makeDump, makeSession } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
import {
dumpFileUrl,
dumpFileVersion,
dumpThumbnailUrl,
} from "../../../utils/shared/urls.ts";
import { dumpFileUrl, dumpThumbnailUrl } from "../../../utils/shared/urls.ts";
import { dumpFileVersion } from "../../../../shared/dump.ts";
describe("FilePreview", () => {
const session = makeSession();

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { markdownToPlainText, mentionsUser } from "../../../utils/chat/text.ts";
import { mentionsUser } from "../../../utils/chat/text.ts";
describe("mentionsUser", () => {
test("case-insensitive whole-word match", () => {
@@ -15,33 +15,3 @@ describe("mentionsUser", () => {
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
});
});
describe("markdownToPlainText", () => {
test("links become their text, images their alt or a label", () => {
expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
"see this now",
);
expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat");
expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image");
});
test("markup characters and block markers go", () => {
expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
"Title quote bold code",
);
});
test("a body cut off mid-link doesn't show a half URL", () => {
expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
"look here",
);
expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe(
"pic alt",
);
expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe(
"pic Image",
);
expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
});
test("whitespace collapses to one line", () => {
expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
});
});

View File

@@ -6,7 +6,7 @@
*/
import { t } from "@lingui/core/macro";
import { markdownToPlainText } from "./text.ts";
import { markdownToPlainText } from "../../../shared/markdown.ts";
import { isEmojiOnly } from "../editor/text.ts";
import { VALIDATION } from "../../../shared/validation.ts";

View File

@@ -1,6 +1,5 @@
/**
* Finds the mentions in a chat message, and flattens its Markdown into reply
* previews.
* Finds the mentions of the signed-in user in a chat message.
*
* @module
*/
@@ -15,24 +14,3 @@ import { mentionedUsernames } from "../../../shared/mention.ts";
export function mentionsUser(body: string, username: string): boolean {
return mentionedUsernames(body).includes(username.toLowerCase());
}
/**
* Returns the Markdown `md` as one line of plain text. Images become their alt
* text, or `imageLabel` without one, and links their text. It takes a
* `replyToBody`, which the server cuts short, possibly inside a link: the
* unfinished link then shows as its text, without half a URL. `snippet` calls
* it.
*/
export function markdownToPlainText(md: string, imageLabel: string): string {
return md
// An image or link cut short at the end loses its unfinished target.
.replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel)
.replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2")
.replace(/!\[[^\]]*$/, "")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt: string) => alt || imageLabel)
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
.replace(/```\w*|\*\*|__|~~|[*`]/g, "")
.replace(/\s+/g, " ")
.trim();
}

View File

@@ -6,6 +6,8 @@
* @module
*/
import { siteHost } from "../../../shared/url.ts";
/**
* Checks whether `value` is a lowercase `#rrggbb` colour. An `accentColor`
* comes from a dumped page's `theme-color`, which anyone controls, and a custom
@@ -37,15 +39,6 @@ function hueFromString(seed: string): string {
return `hsl(${hashString(seed) % 360} 55% 55%)`;
}
function hostnameOf(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* Returns the page's own colour when it declared a valid one, or else a hue
* drawn from `seed` or the hostname of `url`, so that every card gets a tint,
@@ -59,7 +52,7 @@ export function tintFor(
},
): string {
if (isSafeHex(accentColor)) return accentColor;
return hueFromString(seed ?? hostnameOf(url) ?? url ?? "");
return hueFromString(seed ?? (url && siteHost(url)) ?? url ?? "");
}
/**
@@ -70,7 +63,7 @@ export function initialsFor(
siteName: string | undefined,
url: string | undefined,
): string {
const source = siteName?.trim() || hostnameOf(url) || url || "";
const source = siteName?.trim() || (url && siteHost(url)) || url || "";
const letter = [...source].find((ch) => /\p{L}|\p{N}/u.test(ch));
return (letter ?? "?").toUpperCase();
}

View File

@@ -13,6 +13,7 @@ import {
type OwnedItem,
playlistPath,
} from "../../../shared/paths.ts";
import { dumpFileVersion } from "../../../shared/dump.ts";
/**
* Passed to {@link dumpFileUrl} and {@link dumpThumbnailUrl}, usually as a
@@ -26,18 +27,6 @@ export type VersionedDump = {
updatedAt?: Date;
};
/**
* Returns a file dump's version, as `dumpFileVersion` in
* api/services/dump-service.ts computes it, so the two must change together.
* It changes whenever the file can have. The server lets the browser cache a
* URL that carries the current version for good, and has it revalidate any
* other.
*/
export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string {
const at = dump.updatedAt ?? dump.createdAt;
return `${dump.fileSize ?? 0}-${at ? at.getTime() : 0}`;
}
/**
* Returns the URL of the uploaded file of `dump`. The caller passes the
* session's media token (`useAuth`'s `mediaToken`) as `token`, which goes in