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";
|
||||
|
||||
Reference in New Issue
Block a user