diff --git a/package.json b/package.json index ada4cfc..d0d307d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nixamp", - "version": "0.45.5", + "version": "0.46.0", "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.", "license": "MIT", "type": "module", diff --git a/src/break-silence.ts b/src/break-silence.ts new file mode 100644 index 0000000..ce59916 --- /dev/null +++ b/src/break-silence.ts @@ -0,0 +1,65 @@ +/** + * Silence in place of a break, for one listener of a station. + * + * A station's breaks are in its one stream (playlist-input.ts), so a listener + * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes, + * only the spot: while the channel says a break is on, what goes to them is + * MP3 silence lasting as long as the bytes it stands in for. Audio only; a + * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are + * everybody's. + * + * The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is + * resampled to) with empty side information: four header bytes and zeros, + * which every decoder plays as nothing. + */ + +const SAMPLE_RATE = 48_000; +const FRAME_SECONDS = 1152 / SAMPLE_RATE; +/** MPEG-1 Layer III bitrate index by kbps. */ +const BITRATE_INDEX: Record = { + 32: 1, 40: 2, 48: 3, 56: 4, 64: 5, 80: 6, 96: 7, 112: 8, 128: 9, 160: 10, 192: 11, 224: 12, 256: 13, 320: 14, +}; + +/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */ +export function silentFrame(kbps: number): Buffer { + const rate = BITRATE_INDEX[kbps] ? kbps : 128; + const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE); + // Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo. + frame[0] = 0xff; + frame[1] = 0xfb; + frame[2] = (BITRATE_INDEX[rate]! << 4) | (0b01 << 2); + frame[3] = 0x00; + return frame; +} + +export interface Writer { + write: (chunk: Buffer) => boolean; + end: () => void; + pending?: () => number; +} + +/** + * A writer that sends silence instead of whatever is written while `inBreak()` + * says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become + * seconds. + */ +export function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer { + const frame = silentFrame(kbps); + const bytesPerSecond = (kbps * 1000) / 8; + let owed = 0; + return { + write: (chunk: Buffer): boolean => { + if (!inBreak()) { + owed = 0; + return out.write(chunk); + } + owed += chunk.length / bytesPerSecond; + const frames = Math.floor(owed / FRAME_SECONDS); + if (frames === 0) return true; + owed -= frames * FRAME_SECONDS; + return out.write(Buffer.concat(Array.from({ length: frames }, () => frame))); + }, + end: () => out.end(), + ...(out.pending ? { pending: out.pending } : {}), + }; +} diff --git a/src/listen-pass.ts b/src/listen-pass.ts new file mode 100644 index 0000000..c0d308f --- /dev/null +++ b/src/listen-pass.ts @@ -0,0 +1,104 @@ +/** + * The listen pass: what a signed-in nixamp.com listener carries to a station + * on another server, so its breaks go quiet for them. + * + * Breaks are in the stream (playlist-input.ts), one stream for every + * listener, so a station cannot leave a spot out for one person: it can only + * send that person silence while everybody else hears it. To do even that it + * has to know who is signed in, and a station on server1 holds no accounts. + * nixamp.com does, so nixamp.com signs a short pass for a signed-in account + * (GET /api/v1/me/pass, credentialed from any *.nixamp.com page) and the + * station asks nixamp.com whether a pass is good (GET + * /api/v1/listen-pass/check), remembering the answer. + * + * A pass is `v1..` and nothing else: no account + * id, because a station has no use for one and a link gets pasted around. + * Anybody holding a fresh pass hears no breaks until it expires, which is the + * same thing a signed-in listener sharing their screen would give away. + */ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +/** How long a pass is good for. The page asks again on every load. */ +export const PASS_TTL_SECONDS = 24 * 60 * 60; + +/** Where a pass rides on a stream link (an audio element sends no headers). */ +export const PASS_PARAM = "pass"; + +/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */ +export function listenPassSecret(env: NodeJS.ProcessEnv = process.env): string { + if (env["NIXAMP_LISTEN_PASS_SECRET"]) return env["NIXAMP_LISTEN_PASS_SECRET"]; + // Derived, so nixamp.com needs no new variable: a pass is not a session. + const jwt = env["NIXAMP_JWT_SECRET"] ?? ""; + return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : ""; +} + +function sign(secret: string, body: string): string { + return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32); +} + +export function mintListenPass(secret: string, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS): { pass: string; expires: number } { + const expires = Math.floor(nowMs / 1000) + ttlSeconds; + const body = `v1.${expires}`; + return { pass: `${body}.${sign(secret, body)}`, expires }; +} + +/** The expiry of a good pass in seconds, or 0. */ +export function checkListenPass(secret: string, pass: string, nowMs = Date.now()): number { + const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass); + if (!secret || !match) return 0; + const expires = Number(match[1]); + if (expires * 1000 <= nowMs) return 0; + const want = Buffer.from(sign(secret, `v1.${expires}`)); + const got = Buffer.from(match[2]!); + return want.length === got.length && timingSafeEqual(want, got) ? expires : 0; +} + +export interface VerifierOptions { + /** nixamp.com, which signed the pass. */ + site: string; + /** Set on nixamp.com itself, which checks its own passes without asking. */ + secret?: string; + fetcher?: typeof fetch; + now?: () => number; +} + +/** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */ +const REFUSED_MS = 5 * 60 * 1000; +const MAX_REMEMBERED = 5_000; + +/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */ +export class ListenPassVerifier { + /** pass -> whether it is good, and until when that answer stands. */ + private readonly known = new Map(); + + constructor(private readonly options: VerifierOptions) {} + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + async good(pass: string): Promise { + if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass)) return false; + if (this.options.secret) return checkListenPass(this.options.secret, pass, this.now()) > 0; + const seen = this.known.get(pass); + if (seen && seen.until > this.now()) return seen.good; + let expires = 0; + try { + const answer = await (this.options.fetcher ?? fetch)( + `${this.options.site.replace(/\/+$/, "")}/api/v1/listen-pass/check?${PASS_PARAM}=${encodeURIComponent(pass)}`, + { signal: AbortSignal.timeout(2_000) }, + ); + // nixamp.com down is not a refusal: nothing is remembered, and this + // listener hears the break this time. + if (answer.status >= 500) return false; + const body = (await answer.json().catch(() => ({}))) as { ok?: unknown; expires?: unknown }; + expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0; + } catch { + return false; + } + if (this.known.size >= MAX_REMEMBERED) this.known.clear(); + const good = expires * 1000 > this.now(); + this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS }); + return good; + } +} diff --git a/src/server.ts b/src/server.ts index dc50399..950cbff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -122,6 +122,7 @@ import { EventWriter } from "./event-writer.ts"; import { handleLiveApi } from "./live-api.ts"; import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts"; import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts"; +import { ListenPassVerifier, PASS_PARAM, checkListenPass, listenPassSecret, mintListenPass } from "./listen-pass.ts"; import { Layouts } from "./layouts.ts"; import { Rooms } from "./rooms.ts"; import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts"; @@ -1496,6 +1497,9 @@ export function isSignInPath(path: string): boolean { path === "/api/v1/servers" || path.startsWith("/api/v1/servers/") || path === "/api/v1/me/handle" || + // A listen pass is the session's to ask for and anybody's to check. + path === "/api/v1/me/pass" || + path === "/api/v1/listen-pass/check" || path === "/api/v1/me/profile" || path.startsWith("/api/v1/me/profile/") || // A stored photo is public: it is on every class the host runs. @@ -2023,6 +2027,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) { response.once("close", () => tracker.close(id)); }; + // Signs listen passes where accounts live (nixamp.com); "" everywhere else. + const listenPassKey = listenPassSecret(); + const listenPasses = new ListenPassVerifier({ + site: process.env["NIXAMP_SITE"] || "https://nixamp.com", + ...(listenPassKey ? { secret: listenPassKey } : {}), + }); + return async function handle(request: IncomingMessage, response: ServerResponse): Promise { const url = new URL(request.url ?? "/", "http://localhost"); const path = url.pathname; @@ -3059,6 +3070,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) { ...(options.stations.cover ? { cover: options.stations.cover } : {}), ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}), ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}), + passes: listenPasses, ...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}), ...(options.stations.rooms ? { rooms: () => roomEntries(options.stations!.rooms!.store, options.stations!.rooms!.probe, watchBase(options.stations!.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)), @@ -4191,6 +4203,27 @@ export function createHandler(engine: Engine, options: HandlerOptions) { return; } + // A listen pass (listen-pass.ts): a station page on any *.nixamp.com server + // asks for one with this site's cookie, which is same-site there, so a + // signed-in listener hears no breaks. Credentialed, so never "*". + if (path === "/api/v1/me/pass" && request.method === "GET" && options.accounts && listenPassKey) { + const origin = String(request.headers.origin ?? ""); + const cors: Record = /^https:\/\/([a-z0-9-]+\.)*nixamp\.com(:\d+)?$/.test(origin) + ? { "access-control-allow-origin": origin, "access-control-allow-credentials": "true", vary: "origin" } + : {}; + const who = await options.accounts.whoIs(tokenFrom(request.headers)); + const body = who === null ? { error: "not signed in" } : mintListenPass(listenPassKey); + response.writeHead(who === null ? 401 : 200, { ...cors, "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(JSON.stringify(body)); + return; + } + // Asked by a station on another server about a pass it was handed. + if (path === "/api/v1/listen-pass/check" && request.method === "GET" && listenPassKey) { + const expires = checkListenPass(listenPassKey, url.searchParams.get(PASS_PARAM) ?? ""); + json(response, 200, expires > 0 ? { ok: true, expires } : { ok: false }); + return; + } + if (path === "/api/v1/me/handle" && options.handles && options.accounts) { const handles = options.handles; const who = await options.accounts.whoIs(tokenFrom(request.headers)); diff --git a/src/station-page.ts b/src/station-page.ts index c1cf5f6..cc01203 100644 --- a/src/station-page.ts +++ b/src/station-page.ts @@ -197,7 +197,8 @@ export const STATION_APP_CSP = [ "media-src 'self' blob:", "worker-src 'self' blob:", "img-src 'self' data: blob:", - "connect-src 'self'", + // nixamp.com for a listen pass, so a signed-in listener's breaks go quiet. + "connect-src 'self' https://nixamp.com", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'", diff --git a/src/station-routes.ts b/src/station-routes.ts index 80af2e1..6efbaea 100644 --- a/src/station-routes.ts +++ b/src/station-routes.ts @@ -19,6 +19,8 @@ import { alertsFor, type Limits, type StationUsage } from "./station-metering.ts import { aiFromTags, type TrackFacts } from "./track-facts.ts"; import type { Lyrics } from "./lyrics.ts"; import type { PublishReport } from "./lyrics-publish.ts"; +import { quietDuringBreaks } from "./break-silence.ts"; +import { PASS_PARAM } from "./listen-pass.ts"; import { StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore, } from "./stations.ts"; @@ -70,6 +72,8 @@ export interface StationContext { lyrics?: (station: Station, file: string) => Promise; /** Our own songs' words, published back to nichedb (lyrics-publish.ts). */ lyricsPublisher?: { status: () => unknown; publish: (station: Station) => Promise }; + /** Whether a listen pass is good: its holder hears silence in place of a radio station's breaks. */ + passes?: { good: (pass: string) => Promise }; /** Auto stations: run a sync with these settings (and keep them), or read them. */ auto?: { sync: (input: Record) => Promise; setting: () => unknown }; /** The public stations other servers list (guide peers); absent when there are none. */ @@ -325,6 +329,9 @@ export async function handleStations( response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter })); return true; } + // Signed in at nixamp.com: the breaks go quiet. Radio only (break-silence.ts). + const pass = url.searchParams.get(PASS_PARAM) ?? ""; + const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false; response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" }); if (method === "HEAD") { response.end(); @@ -332,7 +339,7 @@ export async function handleStations( } // Every byte that goes out through the link is counted against the station. const usage = ctx.usage; - const counted = usage + const metered = usage ? { write: (chunk: Buffer): boolean => { usage.delivered(station.id, chunk.length); @@ -342,6 +349,9 @@ export async function handleStations( pending: (): number => response.writableLength, } : response; + const counted = spared + ? quietDuringBreaks(metered, station.bitrate, () => ctx.channels.info(channelId)?.playlistAt === -1) + : metered; const detach = ctx.channels.listen(channelId, counted); if (detach === null) { response.end(); diff --git a/src/station-usage.ts b/src/station-usage.ts index 93c8579..a2d871c 100644 --- a/src/station-usage.ts +++ b/src/station-usage.ts @@ -2,7 +2,7 @@ export const USAGE = `nixamp station — make a collection into a station nixamp station list - nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600] + nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)] nixamp station show ID nixamp station status ID nixamp station start ID diff --git a/src/stations.ts b/src/stations.ts index f49b432..815c985 100644 --- a/src/stations.ts +++ b/src/stations.ts @@ -74,6 +74,8 @@ export interface Station { /** Times an item may fail in a row before rotation leaves it out. */ export const EXCLUDE_AFTER = 3; export const DEFAULT_BITRATE = 128; +/** Seconds between breaks on a new station that did not say. */ +export const DEFAULT_BREAKS_EVERY = 600; export const BITRATES = [64, 96, 128, 160, 192, 256, 320]; const MAX_ITEMS = 20_000; const MAX_NAME = 120; @@ -233,7 +235,9 @@ export class StationStore { listenToken: newToken(), bitrate: this.bitrateFrom(input.bitrate), video: input.video === true, - breaksEvery: this.breaksFrom(input.breaksEvery), + // On unless asked for none: a station is free to listen to, and the + // spot is what pays for it. Signed-in listeners hear silence instead. + breaksEvery: input.breaksEvery === undefined ? DEFAULT_BREAKS_EVERY : this.breaksFrom(input.breaksEvery), auto: "", rights: null, owner, diff --git a/test/listen-pass.test.ts b/test/listen-pass.test.ts new file mode 100644 index 0000000..f62ff6e --- /dev/null +++ b/test/listen-pass.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ListenPassVerifier, checkListenPass, listenPassSecret, mintListenPass } from "../src/listen-pass.ts"; +import { quietDuringBreaks, silentFrame } from "../src/break-silence.ts"; + +test("a listen pass is good until it expires, and only under its own secret", () => { + const now = Date.UTC(2026, 9, 20); + const { pass, expires } = mintListenPass("secret", now, 60); + assert.equal(checkListenPass("secret", pass, now), expires); + assert.equal(checkListenPass("other", pass, now), 0); + assert.equal(checkListenPass("secret", pass, now + 61_000), 0); + assert.equal(checkListenPass("secret", pass.replace(/.$/, (c) => (c === "A" ? "B" : "A")), now), 0); + assert.equal(checkListenPass("secret", `v1.${expires + 1000}.${pass.split(".")[2]}`, now), 0); + assert.equal(checkListenPass("", pass, now), 0); +}); + +test("the secret is derived where accounts live and absent elsewhere", () => { + assert.equal(listenPassSecret({}), ""); + assert.match(listenPassSecret({ NIXAMP_JWT_SECRET: "jwt" }), /^[0-9a-f]{64}$/); + assert.equal(listenPassSecret({ NIXAMP_LISTEN_PASS_SECRET: "set", NIXAMP_JWT_SECRET: "jwt" }), "set"); +}); + +test("a station asks nixamp.com about a pass once, and a refusal or an outage is not a pass", async () => { + const now = Date.UTC(2026, 9, 20); + const { pass } = mintListenPass("k", now); + let asked = 0; + let down = false; + const fetcher = (async (url: string) => { + asked += 1; + if (down) return new Response("", { status: 502 }); + const given = new URL(url).searchParams.get("pass") ?? ""; + const expires = checkListenPass("k", given, now); + return Response.json(expires ? { ok: true, expires } : { ok: false }); + }) as typeof fetch; + const verifier = new ListenPassVerifier({ site: "https://nixamp.com/", fetcher, now: () => now }); + assert.equal(await verifier.good(pass), true); + assert.equal(await verifier.good(pass), true); + assert.equal(asked, 1); + const forged = mintListenPass("not-k", now).pass; + assert.equal(await verifier.good(forged), false); + assert.equal(await verifier.good(forged), false); + assert.equal(asked, 2); + assert.equal(await verifier.good("garbage"), false); + assert.equal(asked, 2); + down = true; + assert.equal(await verifier.good(mintListenPass("k", now, 30).pass), false); +}); + +test("nixamp.com checks its own passes without asking anybody", async () => { + const verifier = new ListenPassVerifier({ site: "https://nixamp.com", secret: "k", fetcher: (() => { throw new Error("asked"); }) as typeof fetch }); + assert.equal(await verifier.good(mintListenPass("k").pass), true); + assert.equal(await verifier.good(mintListenPass("x").pass), false); +}); + +test("a silent frame is a valid MPEG-1 Layer III 48 kHz header with empty side information", () => { + const frame = silentFrame(128); + assert.equal(frame.length, 384); + assert.deepEqual([...frame.subarray(0, 4)], [0xff, 0xfb, 0x94, 0x00]); + assert.ok(frame.subarray(4).every((byte) => byte === 0)); + assert.equal(silentFrame(192).length, 576); + assert.equal(silentFrame(999).length, 384); +}); + +test("during a break a spared listener gets silence lasting as long as what it replaces", () => { + const written: Buffer[] = []; + let inBreak = false; + const out = quietDuringBreaks({ write: (chunk) => { written.push(chunk); return true; }, end: () => undefined }, 128, () => inBreak); + const music = Buffer.alloc(1000, 7); + out.write(music); + inBreak = true; + // Five seconds of the spot at 128 kbps, in uneven chunks. + for (let sent = 0; sent < 80_000; sent += 3_333) out.write(Buffer.alloc(Math.min(3_333, 80_000 - sent), 9)); + inBreak = false; + out.write(music); + assert.equal(written[0], music); + assert.equal(written.at(-1), music); + const silence = Buffer.concat(written.slice(1, -1)); + assert.ok(!silence.includes(9)); + // 5 s is 208.33 frames of 24 ms; never more than was owed. + assert.equal(silence.length % 384, 0); + assert.ok(silence.length / 384 >= 207 && silence.length / 384 <= 208, String(silence.length / 384)); +}); diff --git a/web/src/app.ts b/web/src/app.ts index c57b60e..a82f27b 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -941,8 +941,19 @@ export function start(): void { // // The day a pass exists, `paid: true` is what suppresses a break, and it // gets passed from wherever that pass is known. + // + // That day is a listen pass (listen-pass.ts): signed in at nixamp.com is + // the pass, so a signed-in listener gets no breaks here either. void (async () => { - player.enableAds(adStage as HTMLElement, await adSettings({}, openedWith)); + let signedIn = false; + try { + const site = location.hostname === "nixamp.com" ? "" : "https://nixamp.com"; + const r = await fetch(`${site}/api/v1/me/pass`, { credentials: "include", cache: "no-store", signal: AbortSignal.timeout(3000) }); + signedIn = r.ok; + } catch { + // Unknown is not signed in. + } + player.enableAds(adStage as HTMLElement, await adSettings(signedIn ? { paid: true } : {}, openedWith)); // ?adNow plays one straight away. Without it, seeing an advert means // first finding something to play and then waiting out the interval, diff --git a/web/src/station.ts b/web/src/station.ts index dd27154..37ef212 100644 --- a/web/src/station.ts +++ b/web/src/station.ts @@ -184,12 +184,28 @@ const unmount = (): void => { old?.destroy(); }; +/** + * Signed in at nixamp.com: a listen pass, and this station's breaks are + * silence for us. The cookie is nixamp.com's, which every *.nixamp.com server + * shares a site with; anywhere else the answer is no pass and the breaks play. + */ +let pass = ""; +const passAsked = (async (): Promise => { + const site = location.hostname === "nixamp.com" ? "" : "https://nixamp.com"; + try { + const r = await fetch(site + "/api/v1/me/pass", { credentials: "include", cache: "no-store", signal: AbortSignal.timeout(3000) }); + if (r.ok) pass = String(((await r.json()) as { pass?: unknown }).pass ?? ""); + } catch { + // Not signed in, or nixamp.com out of reach: the breaks play. + } +})(); + /** A fresh request is always the live edge: never a cached or paused one. */ const mount = (autoplay: boolean): void => { unmount(); const video = kind === "video"; const current = createPlayer($("#stage"), { - src: api + "/stream" + q + "&n=" + Date.now(), + src: api + "/stream" + q + (pass ? "&pass=" + encodeURIComponent(pass) : "") + "&n=" + Date.now(), kind: video ? "mp4" : "audio", audio: !video, live: true, @@ -263,7 +279,8 @@ function draw(s: StationState): void { // that changes (a station turned into TV). if (onAir && kind !== s.kind) { kind = s.kind; - mount(wanted); + // After the pass is known, so the first stream already has it. + void passAsked.then(() => mount(wanted)); } }