Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
/**
|
|
* Resolves a Bandcamp page to its streamable tracks when the native player
|
|
* starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and
|
|
* expire 24 hours after the page is fetched, so unlike the embed URL they
|
|
* can't be stored in the dump's preview. The browser plays them from bcbits
|
|
* directly.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIException } from "../lib/api-exception.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.
|
|
*/
|
|
const TTL_MS = 12 * 60 * 60 * 1000;
|
|
/** The oldest write goes first. */
|
|
const MAX_ENTRIES = 200;
|
|
|
|
const cache = new Map<string, Tralbum>();
|
|
|
|
/**
|
|
* Returns `url` without its query, fragment and trailing slashes, and with its
|
|
* host in lowercase.
|
|
*/
|
|
function cacheKey(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
return `${u.protocol}//${u.hostname.toLowerCase()}${
|
|
u.pathname.replace(/\/+$/, "")
|
|
}`;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
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
|
|
* it when it re-resolves a queue whose URLs expired or are about to, which a
|
|
* cached entry could hand back.
|
|
*
|
|
* @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL,
|
|
* 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when
|
|
* the page has nothing streamable.
|
|
*/
|
|
export async function resolveBandcamp(
|
|
pageUrl: string,
|
|
{ force = false }: { force?: boolean } = {},
|
|
): Promise<Tralbum> {
|
|
if (!isValidHttpUrl(pageUrl)) {
|
|
throw new APIException(400, "Invalid URL");
|
|
}
|
|
|
|
let hostname: string;
|
|
try {
|
|
hostname = new URL(pageUrl).hostname;
|
|
} catch {
|
|
throw new APIException(400, "Invalid URL");
|
|
}
|
|
if (!BANDCAMP_HOST_RE.test(hostname)) {
|
|
throw new APIException(400, "Not a Bandcamp URL");
|
|
}
|
|
|
|
const key = cacheKey(pageUrl);
|
|
if (!force) {
|
|
const hit = readCache(key);
|
|
if (hit) return hit;
|
|
}
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout(pageUrl, 8000);
|
|
} catch {
|
|
throw new APIException(502, "Could not reach Bandcamp");
|
|
}
|
|
// A body left unread keeps its connection open until GC, and this endpoint
|
|
// is public: every early return cancels it.
|
|
if (!res.ok) {
|
|
await res.body?.cancel();
|
|
throw new APIException(502, `Bandcamp returned ${res.status}`);
|
|
}
|
|
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
|
await res.body?.cancel();
|
|
throw new APIException(502, "Bandcamp did not return a page");
|
|
}
|
|
|
|
const parsed = parseTralbum(await readTextCapped(res), pageUrl);
|
|
if (!parsed) {
|
|
// No data, no audio or nothing streamable yet (a preorder). The client
|
|
// plays the embed instead.
|
|
throw new APIException(404, "No streamable tracks on this page");
|
|
}
|
|
|
|
writeCache(key, parsed);
|
|
return parsed;
|
|
}
|