v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13
api/lib/api-exception.ts
Normal file
13
api/lib/api-exception.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIErrorCode } from "../../shared/api.ts";
|
||||
|
||||
/** Thrown anywhere below a route; errorMiddleware turns it into a response. */
|
||||
export class APIException extends Error {
|
||||
readonly code: APIErrorCode;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: APIErrorCode, status: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Context } from "@oak/oak";
|
||||
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type AuthPayload,
|
||||
type Role,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db } from "../model/db.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
import type { Role } from "../../shared/user.ts";
|
||||
import { getSessionState } from "../services/user-service.ts";
|
||||
|
||||
/**
|
||||
* Checks a signature-verified token against the live user row: the user must
|
||||
@@ -21,19 +19,15 @@ function checkTokenOwner(
|
||||
userId: string,
|
||||
iat: number | undefined,
|
||||
): { role: Role } | null {
|
||||
const row = db.prepare(
|
||||
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
|
||||
).get(userId) as
|
||||
| { role: Role; tokens_valid_after: string | null }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
if (row.tokens_valid_after) {
|
||||
const state = getSessionState(userId);
|
||||
if (!state) return null;
|
||||
if (state.tokensValidAfter) {
|
||||
const cutoff = Math.floor(
|
||||
new Date(row.tokens_valid_after).getTime() / 1000,
|
||||
new Date(state.tokensValidAfter).getTime() / 1000,
|
||||
);
|
||||
if ((iat ?? 0) < cutoff) return null;
|
||||
}
|
||||
return { role: row.role };
|
||||
return { role: state.role };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
isAuthPayload,
|
||||
isPasswordResetPayload,
|
||||
type PasswordResetPayload,
|
||||
} from "../model/interfaces.ts";
|
||||
} from "../model/auth.ts";
|
||||
import { JWT_SECRET } from "../config.ts";
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
import { type AuthPayload, type Role } from "../model/interfaces.ts";
|
||||
|
||||
// Named capabilities checked at enforcement points. Extend this union as new
|
||||
// gated capabilities are added (e.g. "invite:manage").
|
||||
export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "chat:moderate"
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
// Roles are fixed in code. `admin` is a superset of every permission (current
|
||||
// and future); `moderator` gets the content-moderation permissions only.
|
||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
],
|
||||
user: [],
|
||||
};
|
||||
|
||||
export function hasPermission(role: Role, permission: Permission): boolean {
|
||||
return ROLE_PERMISSIONS[role].includes(permission);
|
||||
}
|
||||
import { hasPermission, type Permission } from "../../shared/permissions.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
|
||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||
return hasPermission(payload.role, permission);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Playlist } from "../model/interfaces.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/** A playlist's dump, with what deciding who may see it takes. */
|
||||
export interface PlaylistMember {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Guards for server-side fetches of user-supplied URLs (link previews, the
|
||||
* image proxy, Bandcamp resolution): the server must not become a way to reach
|
||||
* addresses only it can see — loopback, the LAN, container networks, or the
|
||||
* cloud metadata endpoint at 169.254.169.254.
|
||||
*/
|
||||
|
||||
function parseIPv4(s: string): number[] | null {
|
||||
const parts = s.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
||||
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
||||
}
|
||||
|
||||
function isPrivateIPv4([a, b]: number[]): boolean {
|
||||
return a === 0 || // "this" network
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
|
||||
(a === 169 && b === 254) || // link-local, cloud metadata
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0) || // IETF protocol assignments (192.0.0.0/24 …)
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) || // benchmarking
|
||||
a >= 224; // multicast, reserved, broadcast
|
||||
}
|
||||
|
||||
/** 16 bytes, or null when `s` isn't an IPv6 address. */
|
||||
function parseIPv6(s: string): number[] | null {
|
||||
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
|
||||
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
|
||||
const lastColon = addr.lastIndexOf(":");
|
||||
if (lastColon === -1) return null;
|
||||
const tail = addr.slice(lastColon + 1);
|
||||
if (tail.includes(".")) {
|
||||
const v4 = parseIPv4(tail);
|
||||
if (!v4) return null;
|
||||
addr = `${addr.slice(0, lastColon + 1)}${
|
||||
((v4[0] << 8) | v4[1]).toString(16)
|
||||
}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
||||
}
|
||||
const halves = addr.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(":") : [];
|
||||
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||
const fill = 8 - head.length - rest.length;
|
||||
if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
|
||||
const groups = [
|
||||
...head,
|
||||
...Array(halves.length === 2 ? fill : 0).fill("0"),
|
||||
...rest,
|
||||
];
|
||||
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
||||
return groups.flatMap((g) => {
|
||||
const n = parseInt(g, 16);
|
||||
return [n >> 8, n & 0xff];
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivateIPv6(b: number[]): boolean {
|
||||
const zeroPrefix = (n: number) => b.slice(0, n).every((x) => x === 0);
|
||||
// IPv4 carried inside IPv6 is judged as the IPv4 address it carries.
|
||||
if (zeroPrefix(10) && b[10] === 0xff && b[11] === 0xff) { // ::ffff:a.b.c.d
|
||||
return isPrivateIPv4(b.slice(12));
|
||||
}
|
||||
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
// 64:ff9b::/96 NAT64
|
||||
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
|
||||
b.slice(4, 12).every((x) => x === 0)
|
||||
) return isPrivateIPv4(b.slice(12));
|
||||
if (b[0] === 0x20 && b[1] === 0x02) { // 2002::/16 6to4
|
||||
return isPrivateIPv4(b.slice(2, 6));
|
||||
}
|
||||
return (b[0] & 0xfe) === 0xfc || // fc00::/7 unique local
|
||||
(b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local
|
||||
(b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) || // fec0::/10 site-local
|
||||
b[0] === 0xff; // multicast
|
||||
}
|
||||
|
||||
/** True for an IP literal outside the public internet. Non-IPs are false. */
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = parseIPv4(ip);
|
||||
if (v4) return isPrivateIPv4(v4);
|
||||
const v6 = parseIPv6(ip);
|
||||
return v6 ? isPrivateIPv6(v6) : false;
|
||||
}
|
||||
|
||||
function isIpLiteral(host: string): boolean {
|
||||
return parseIPv4(host) !== null || parseIPv6(host) !== null;
|
||||
}
|
||||
|
||||
/** `URL.hostname` keeps the brackets around an IPv6 literal. */
|
||||
function bareHostname(url: URL): string {
|
||||
return url.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous part of the check: a hostname that is a private IP literal,
|
||||
* or a name that is local by definition. Names still need resolving (see
|
||||
* resolvePublicAddress) — this only rejects what is obvious without DNS.
|
||||
*/
|
||||
export function isPrivateHostname(url: URL): boolean {
|
||||
const host = bareHostname(url).toLowerCase();
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
return isPrivateAddress(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `url`'s host and returns one of its addresses, or throws if the
|
||||
* host is private or any address it resolves to is (a name with both a public
|
||||
* and a private record is refused outright, since the fetch may pick either).
|
||||
*
|
||||
* The fetch itself resolves again, so a DNS server answering differently the
|
||||
* second time (rebinding) isn't fully excluded; the curl fallback pins the
|
||||
* address this returns.
|
||||
*/
|
||||
export async function resolvePublicAddress(url: URL): Promise<string> {
|
||||
if (isPrivateHostname(url)) {
|
||||
throw new Error(`Refusing to fetch private address ${url.hostname}`);
|
||||
}
|
||||
const host = bareHostname(url);
|
||||
if (isIpLiteral(host)) return host;
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
Deno.resolveDns(host, "A"),
|
||||
Deno.resolveDns(host, "AAAA"),
|
||||
]);
|
||||
const addresses = results.flatMap((r) =>
|
||||
r.status === "fulfilled" ? r.value : []
|
||||
);
|
||||
if (addresses.length === 0) {
|
||||
throw new Error(`Could not resolve ${host}`);
|
||||
}
|
||||
const blocked = addresses.find(isPrivateAddress);
|
||||
if (blocked) {
|
||||
throw new Error(`Refusing to fetch ${host}: resolves to ${blocked}`);
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
@@ -7,9 +7,8 @@
|
||||
// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
|
||||
// prod, public/ in dev) — see that script for how the output dir is chosen.
|
||||
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
||||
import { fetchTwemojiSvg } from "../remote/twemoji.ts";
|
||||
|
||||
// Pinned Twemoji release.
|
||||
const TWEMOJI_VERSION = "16.0.1";
|
||||
const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512];
|
||||
const APPLE_SIZE = 180;
|
||||
const ICO_SIZES = [16, 32, 48];
|
||||
@@ -35,34 +34,6 @@ function ensureWasm(): Promise<unknown> {
|
||||
return wasmReady;
|
||||
}
|
||||
|
||||
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
|
||||
const glyphCache = new Map<string, string>();
|
||||
|
||||
async function fetchGlyph(emoji: string): Promise<string> {
|
||||
const cp = emojiToCodepoint(emoji);
|
||||
const cached = glyphCache.get(cp);
|
||||
if (cached) return cached;
|
||||
const url =
|
||||
`https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const svg = await res.text();
|
||||
glyphCache.set(cp, svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||
const resvg = new Resvg(svg, {
|
||||
fitTo: { mode: "width", value: size },
|
||||
@@ -143,7 +114,7 @@ export async function generateSiteIcons(
|
||||
opts: GenerateSiteIconsOptions,
|
||||
): Promise<void> {
|
||||
const { emoji, outDir, siteName, bgColor } = opts;
|
||||
const svg = await fetchGlyph(emoji);
|
||||
const svg = await fetchTwemojiSvg(emoji, emojiToCodepoint(emoji));
|
||||
await ensureWasm();
|
||||
|
||||
await Deno.mkdir(outDir, { recursive: true });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import { serveFile } from "./serve-file.ts";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
|
||||
Reference in New Issue
Block a user