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:
@@ -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
91
api/lib/css-color.ts
Normal 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("")
|
||||
}`;
|
||||
}
|
||||
@@ -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
60
api/lib/ttl-cache.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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") ??
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { db, transaction } from "../db/connection.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { attachmentIdsIn } from "../../shared/attachment.ts";
|
||||
|
||||
/**
|
||||
* Records attachment `id` once `POST /api/attachments` has written its file.
|
||||
@@ -108,9 +109,6 @@ function resourceOwner(
|
||||
}
|
||||
}
|
||||
|
||||
const ATTACHMENT_RE =
|
||||
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
|
||||
|
||||
/**
|
||||
* Binds the attachments whose URLs appear in `text` to the resource that saved
|
||||
* it, so the startup purge keeps them as long as that resource exists, and
|
||||
@@ -124,7 +122,7 @@ export function linkAttachments(
|
||||
resourceType: AttachmentResourceType,
|
||||
resourceId: string,
|
||||
): void {
|
||||
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
||||
const ids = attachmentIdsIn(text);
|
||||
if (ids.length === 0) return;
|
||||
const update = db.prepare(
|
||||
"UPDATE attachments SET resource_type = ?, resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
||||
|
||||
@@ -22,27 +22,17 @@ import {
|
||||
} from "../model/dump.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { isReservedSlug } from "../lib/reserved-slugs.ts";
|
||||
import { SLUG_RE } from "../../shared/slug.ts";
|
||||
import { validateSlug } from "../lib/slugify.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
|
||||
* with a message for each case.
|
||||
* Throws a 400 unless `slug` is a valid category slug: a slug within a
|
||||
* category's length, and none of the top-level paths other pages hold
|
||||
* (reserved-slugs.ts), since a category's feed lives at the URL root.
|
||||
*/
|
||||
function validateSlug(slug: string): void {
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
throw new APIException(
|
||||
400,
|
||||
"Slug must be lowercase letters, numbers and hyphens",
|
||||
);
|
||||
}
|
||||
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
|
||||
throw new APIException(
|
||||
400,
|
||||
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
|
||||
);
|
||||
}
|
||||
function validateCategorySlug(slug: string): void {
|
||||
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
|
||||
if (isReservedSlug(slug)) {
|
||||
throw new APIException(400, `Slug "${slug}" is reserved`);
|
||||
}
|
||||
@@ -133,7 +123,7 @@ export function listCategories(): Category[] {
|
||||
* either is taken.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateSlug(req.slug);
|
||||
validateCategorySlug(req.slug);
|
||||
const name = requireName(req.name);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
@@ -177,7 +167,7 @@ export function updateCategory(
|
||||
|
||||
const newSlug = req.slug ?? existing.slug;
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateSlug(req.slug);
|
||||
validateCategorySlug(req.slug);
|
||||
}
|
||||
const newName = req.name !== undefined
|
||||
? requireName(req.name)
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
PERIOD_MS,
|
||||
} from "../../shared/settings.ts";
|
||||
import type { CommunityStats } from "../../shared/community.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import { siteHost } from "../../shared/url.ts";
|
||||
import { markdownToPlainText } from "../../shared/markdown.ts";
|
||||
import {
|
||||
DUMP_SELECT_COLUMNS_ALIASED,
|
||||
dumpRowToApi,
|
||||
@@ -61,7 +63,6 @@ import { lookOf } from "../mail/layout.ts";
|
||||
import { createUnsubscribeToken } from "../lib/jwt.ts";
|
||||
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
||||
import { getSettings } from "./settings-service.ts";
|
||||
import { dumpFileVersion } from "./dump-service.ts";
|
||||
import { withCurrentRefs } from "./notification-service.ts";
|
||||
import { communityStatsOf } from "./community-service.ts";
|
||||
|
||||
@@ -93,12 +94,7 @@ interface Window {
|
||||
/** Flattens Markdown into a short line of text for a quote. */
|
||||
function quoteOf(markdown: string | undefined | null): string | undefined {
|
||||
if (!markdown) return undefined;
|
||||
const flat = markdown
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[`*_>#~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const flat = markdownToPlainText(markdown, "");
|
||||
if (!flat) return undefined;
|
||||
return flat.length > QUOTE_MAX
|
||||
? `${flat.slice(0, QUOTE_MAX - 1).trimEnd()}…`
|
||||
@@ -131,14 +127,8 @@ function thumbnailOf(dump: Dump): string | null {
|
||||
}
|
||||
|
||||
function toDigestDump(dump: Dump, readerId: string): DigestDump {
|
||||
let domain: string | null = null;
|
||||
if (dump.kind === "url" && dump.url) {
|
||||
try {
|
||||
domain = new URL(dump.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
domain = null;
|
||||
}
|
||||
}
|
||||
const domain = (dump.kind === "url" && dump.url && siteHost(dump.url)) ||
|
||||
null;
|
||||
return {
|
||||
title: dump.title,
|
||||
url: `${FRONTEND_URL}${dumpPath(dump)}`,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
DumpUrlMatch,
|
||||
UpdateDumpRequest,
|
||||
} from "../../shared/dump.ts";
|
||||
import { siteHost } from "../../shared/url.ts";
|
||||
import { db, transaction } from "../db/connection.ts";
|
||||
import {
|
||||
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
||||
@@ -83,15 +84,6 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a link's host name, the title it gets when its page has none. */
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the followers of a dump that went public, `before` being private
|
||||
* and `after` public: the poster's, as if it were just posted, and those of
|
||||
@@ -129,7 +121,7 @@ export async function createUrlDump(
|
||||
// A title typed on the create form wins over the one scraped from the page:
|
||||
// the poster has seen the preview and is correcting it.
|
||||
const title = request.title?.trim() || richContent?.title ||
|
||||
titleFromUrl(request.url);
|
||||
(siteHost(request.url) ?? request.url);
|
||||
const isPrivate = request.isPrivate ?? false;
|
||||
|
||||
const categoryIds = [...new Set(request.categoryIds ?? [])];
|
||||
@@ -244,20 +236,6 @@ export async function createFileDump(
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a file dump's version, which the app's file and thumbnail URLs carry
|
||||
* as `?v=`. It changes whenever the file may have, so the file and thumbnail
|
||||
* routes cache a URL naming the current one for good. The frontend's
|
||||
* `dumpFileVersion` must compute the same.
|
||||
*/
|
||||
export function dumpFileVersion(
|
||||
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
||||
): string {
|
||||
return `${dump.fileSize ?? 0}-${
|
||||
(dump.updatedAt ?? dump.createdAt).getTime()
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
|
||||
* upload, once the upload is replaced or deleted. The route makes them again
|
||||
@@ -542,7 +520,7 @@ export async function updateDump(
|
||||
|
||||
if (newUrl !== dump.url) {
|
||||
richContent = await fetchRichContent(newUrl);
|
||||
title = richContent?.title ?? titleFromUrl(newUrl);
|
||||
title = richContent?.title ?? siteHost(newUrl) ?? newUrl;
|
||||
}
|
||||
|
||||
if (request.title?.trim()) {
|
||||
@@ -819,7 +797,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
);
|
||||
}
|
||||
|
||||
const title = richContent?.title ?? titleFromUrl(dump.url);
|
||||
const title = richContent?.title ?? siteHost(dump.url) ?? dump.url;
|
||||
|
||||
const updatedDump: Dump = { ...dump, title, richContent };
|
||||
const row = dumpApiToRow(updatedDump);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../shared/dump.ts";
|
||||
import { siteHost } from "../../shared/url.ts";
|
||||
import { TtlCache } from "../lib/ttl-cache.ts";
|
||||
import type { RichContentProvider } from "../remote/providers/provider.ts";
|
||||
import { youtubeProvider } from "../remote/providers/youtube.ts";
|
||||
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
|
||||
@@ -47,19 +49,12 @@ export interface FetchRichContentResult {
|
||||
content?: RichContent;
|
||||
}
|
||||
|
||||
/** How long a fetched preview is reused. */
|
||||
const RECENT_TTL_MS = 5 * 60 * 1000;
|
||||
/** How many previews are kept. The oldest written goes first. */
|
||||
const RECENT_MAX = 200;
|
||||
/**
|
||||
* Makes posting a link fast after the create form previewed it, by keeping the
|
||||
* previews fetched in the last few minutes, by URL. Without it, the second
|
||||
* previews fetched in the last five minutes, by URL. Without it, the second
|
||||
* fetch of the page was the slow part of posting. Only successes are kept.
|
||||
*/
|
||||
const recent = new Map<
|
||||
string,
|
||||
{ at: number; result: Promise<FetchRichContentResult> }
|
||||
>();
|
||||
const recent = new TtlCache<Promise<FetchRichContentResult>>(5 * 60_000, 200);
|
||||
|
||||
/**
|
||||
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
|
||||
@@ -78,20 +73,13 @@ export function tryFetchRichContent(
|
||||
url: string,
|
||||
{ fresh = false }: { fresh?: boolean } = {},
|
||||
): Promise<FetchRichContentResult> {
|
||||
const hit = recent.get(url);
|
||||
if (!fresh && hit && Date.now() - hit.at < RECENT_TTL_MS) return hit.result;
|
||||
recent.delete(url);
|
||||
const hit = fresh ? undefined : recent.get(url);
|
||||
if (hit) return hit;
|
||||
|
||||
// Stored while in flight too, so a preview and a post racing share a fetch.
|
||||
const result = fetchRichContentUncached(url);
|
||||
if (recent.size >= RECENT_MAX) {
|
||||
const oldest = recent.keys().next().value;
|
||||
if (oldest !== undefined) recent.delete(oldest);
|
||||
}
|
||||
const entry = { at: Date.now(), result };
|
||||
recent.set(url, entry);
|
||||
const result = recent.set(url, fetchRichContentUncached(url));
|
||||
result.then(({ ok }) => {
|
||||
if (!ok && recent.get(url) === entry) recent.delete(url);
|
||||
if (!ok) recent.delete(url, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -108,18 +96,10 @@ async function fetchRichContentUncached(
|
||||
return { ok: true, content: await provider.fetch(url) };
|
||||
} catch (err) {
|
||||
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
||||
try {
|
||||
return {
|
||||
ok: false,
|
||||
content: {
|
||||
type: "generic",
|
||||
url,
|
||||
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
const siteName = siteHost(url);
|
||||
return siteName
|
||||
? { ok: false, content: { type: "generic", url, siteName } }
|
||||
: { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,11 @@ function sendRaw(socket: WebSocket, payload: string): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(payload);
|
||||
}
|
||||
|
||||
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
/**
|
||||
* Sends `data` to one socket unless it is closing. The WebSocket route answers
|
||||
* a client's own message with it: a pong, a vote's ack, an error.
|
||||
*/
|
||||
export function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
sendRaw(socket, JSON.stringify(data));
|
||||
}
|
||||
|
||||
|
||||
37
api/test/lib/ttl-cache.test.ts
Normal file
37
api/test/lib/ttl-cache.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
37
shared/attachment.ts
Normal 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);
|
||||
}
|
||||
@@ -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
28
shared/markdown.ts
Normal 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();
|
||||
}
|
||||
35
shared/test/markdown.test.ts
Normal file
35
shared/test/markdown.test.ts
Normal 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("", "Image"), "cat");
|
||||
assertEquals(markdownToPlainText("", "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 ,
|
||||
"pic alt",
|
||||
);
|
||||
assertEquals(
|
||||
markdownToPlainText("pic ,
|
||||
"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
28
shared/url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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(``);
|
||||
images.push(`})`);
|
||||
} catch {
|
||||
setNotice(
|
||||
files.length > 1
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("", "Image")).toBe("cat");
|
||||
expect(markdownToPlainText("", "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 ).toBe(
|
||||
"pic alt",
|
||||
);
|
||||
expect(markdownToPlainText("pic ).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user