diff --git a/api/lib/canonical-url.ts b/api/lib/canonical-url.ts index 2f7c4246..00b567c2 100644 --- a/api/lib/canonical-url.ts +++ b/api/lib/canonical-url.ts @@ -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); diff --git a/api/lib/css-color.ts b/api/lib/css-color.ts new file mode 100644 index 00000000..dc81fa44 --- /dev/null +++ b/api/lib/css-color.ts @@ -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 = { + 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("") + }`; +} diff --git a/api/lib/slugify.ts b/api/lib/slugify.ts index 592c6a82..ce98cdaf 100644 --- a/api/lib/slugify.ts +++ b/api/lib/slugify.ts @@ -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`, ); } } diff --git a/api/lib/ttl-cache.ts b/api/lib/ttl-cache.ts new file mode 100644 index 00000000..e9d720f5 --- /dev/null +++ b/api/lib/ttl-cache.ts @@ -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(6 * 3_600_000, 200); + * const hit = playlists.get(listId) ?? playlists.set(listId, await fetch()); + */ +export class TtlCache { + #entries = new Map(); + + 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(); + } +} diff --git a/api/mail/theme-tokens.ts b/api/mail/theme-tokens.ts index 9690cccf..a2259ec3 100644 --- a/api/mail/theme-tokens.ts +++ b/api/mail/theme-tokens.ts @@ -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. */ diff --git a/api/remote/bandcamp-streams.ts b/api/remote/bandcamp-streams.ts index 94a1e171..af867482 100644 --- a/api/remote/bandcamp-streams.ts +++ b/api/remote/bandcamp-streams.ts @@ -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(); +const cache = new TtlCache(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); } diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts index a72f1884..6109f778 100644 --- a/api/remote/fetch.ts +++ b/api/remote/fetch.ts @@ -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 diff --git a/api/remote/html.ts b/api/remote/html.ts index ea7f38ec..57e2f9d6 100644 --- a/api/remote/html.ts +++ b/api/remote/html.ts @@ -7,6 +7,8 @@ * @module */ +import { hexOf, parseCssColor } from "../lib/css-color.ts"; + /** * Decodes the five XML entities and numeric ones in `str`. `&` goes first, * so `&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 = { - 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; } /** diff --git a/api/remote/providers/generic.ts b/api/remote/providers/generic.ts index e1a7a309..c3b58c38 100644 --- a/api/remote/providers/generic.ts +++ b/api/remote/providers/generic.ts @@ -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") ?? diff --git a/api/remote/providers/youtube.ts b/api/remote/providers/youtube.ts index 8780cb28..45d16945 100644 --- a/api/remote/providers/youtube.ts +++ b/api/remote/providers/youtube.ts @@ -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; diff --git a/api/remote/public-address.ts b/api/remote/public-address.ts index 096c417f..ac71afe3 100644 --- a/api/remote/public-address.ts +++ b/api/remote/public-address.ts @@ -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"); } diff --git a/api/remote/youtube-playlist.ts b/api/remote/youtube-playlist.ts index 1599720f..331b2df2 100644 --- a/api/remote/youtube-playlist.ts +++ b/api/remote/youtube-playlist.ts @@ -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(); +const cache = new TtlCache(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); } diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index 714115f4..fc14dd28 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -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"; diff --git a/api/routes/files.ts b/api/routes/files.ts index a24d6c73..037f2a7c 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -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"); } diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index e0b38e91..ac7870f6 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -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"); } diff --git a/api/routes/ws.ts b/api/routes/ws.ts index bf5facda..f812859c 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -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 diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index 415b9348..af8eefb4 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -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", diff --git a/api/services/category-service.ts b/api/services/category-service.ts index e6608de3..729e2bdd 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -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) diff --git a/api/services/digest-service.ts b/api/services/digest-service.ts index 3f10391d..1e98fc1e 100644 --- a/api/services/digest-service.ts +++ b/api/services/digest-service.ts @@ -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)}`, diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 1bb5f798..55b3ff85 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -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, -): 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 { ); } - 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); diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 64db6f8a..3ea1629d 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -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 } ->(); +const recent = new TtlCache>(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 { - 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 }; } } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 512734ab..5707e9be 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -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)); } diff --git a/api/test/lib/ttl-cache.test.ts b/api/test/lib/ttl-cache.test.ts new file mode 100644 index 00000000..46904fb8 --- /dev/null +++ b/api/test/lib/ttl-cache.test.ts @@ -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(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(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(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); +}); diff --git a/api/test/remote/html.test.ts b/api/test/remote/html.test.ts index 8479a46d..e34dddcc 100644 --- a/api/test/remote/html.test.ts +++ b/api/test/remote/html.test.ts @@ -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); }); diff --git a/api/test/routes/attachments.test.ts b/api/test/routes/attachments.test.ts index ed51b8f1..ae1e5db3 100644 --- a/api/test/routes/attachments.test.ts +++ b/api/test/routes/attachments.test.ts @@ -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"; diff --git a/shared/attachment.ts b/shared/attachment.ts new file mode 100644 index 00000000..4ea1377c --- /dev/null +++ b/shared/attachment.ts @@ -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); +} diff --git a/shared/dump.ts b/shared/dump.ts index 413188a1..2285ad53 100644 --- a/shared/dump.ts +++ b/shared/dump.ts @@ -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}`; } diff --git a/shared/markdown.ts b/shared/markdown.ts new file mode 100644 index 00000000..8ac21f4e --- /dev/null +++ b/shared/markdown.ts @@ -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(); +} diff --git a/shared/test/markdown.test.ts b/shared/test/markdown.test.ts new file mode 100644 index 00000000..958c2f90 --- /dev/null +++ b/shared/test/markdown.test.ts @@ -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"); +}); diff --git a/shared/url.ts b/shared/url.ts new file mode 100644 index 00000000..c8eb1db4 --- /dev/null +++ b/shared/url.ts @@ -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; + } +} diff --git a/shared/validation.ts b/shared/validation.ts index a4057db3..8ad5aac3 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -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. diff --git a/src/components/editor/Markdown.tsx b/src/components/editor/Markdown.tsx index 30f1edda..3f06fbd7 100644 --- a/src/components/editor/Markdown.tsx +++ b/src/components/editor/Markdown.tsx @@ -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["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 {alt; diff --git a/src/components/editor/TextEditor.tsx b/src/components/editor/TextEditor.tsx index 4ae474fa..1e7f628b 100644 --- a/src/components/editor/TextEditor.tsx +++ b/src/components/editor/TextEditor.tsx @@ -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 diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx index 9b3fcbbe..1d215e0b 100644 --- a/src/test/components/dump/FilePreview.test.tsx +++ b/src/test/components/dump/FilePreview.test.tsx @@ -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(); diff --git a/src/test/utils/chat/text.test.ts b/src/test/utils/chat/text.test.ts index ff99e60c..fe063dd5 100644 --- a/src/test/utils/chat/text.test.ts +++ b/src/test/utils/chat/text.test.ts @@ -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"); - }); -}); diff --git a/src/utils/chat/message.ts b/src/utils/chat/message.ts index 1d4639e6..acbb67f7 100644 --- a/src/utils/chat/message.ts +++ b/src/utils/chat/message.ts @@ -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"; diff --git a/src/utils/chat/text.ts b/src/utils/chat/text.ts index 6c9f7023..97f639ce 100644 --- a/src/utils/chat/text.ts +++ b/src/utils/chat/text.ts @@ -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(); -} diff --git a/src/utils/shared/thumbnailTint.ts b/src/utils/shared/thumbnailTint.ts index 242198b8..bfc7de17 100644 --- a/src/utils/shared/thumbnailTint.ts +++ b/src/utils/shared/thumbnailTint.ts @@ -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(); } diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 2e275d74..70db8c72 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -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): 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