Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
65 changes: 65 additions & 0 deletions src/break-silence.ts
Original file line number Diff line number Diff line change
@@ -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<number, number> = {
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 } : {}),
};
}
104 changes: 104 additions & 0 deletions src/listen-pass.ts
Original file line number Diff line number Diff line change
@@ -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.<expiry seconds>.<signature>` 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<string, { good: boolean; until: number }>();

constructor(private readonly options: VerifierOptions) {}

private now(): number {
return this.options.now?.() ?? Date.now();
}

async good(pass: string): Promise<boolean> {
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;
}
}
33 changes: 33 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
const url = new URL(request.url ?? "/", "http://localhost");
const path = url.pathname;
Expand Down Expand Up @@ -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"}`)),
Expand Down Expand Up @@ -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<string, string> = /^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));
Expand Down
3 changes: 2 additions & 1 deletion src/station-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
12 changes: 11 additions & 1 deletion src/station-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,6 +72,8 @@ export interface StationContext {
lyrics?: (station: Station, file: string) => Promise<Lyrics | null>;
/** Our own songs' words, published back to nichedb (lyrics-publish.ts). */
lyricsPublisher?: { status: () => unknown; publish: (station: Station) => Promise<PublishReport> };
/** Whether a listen pass is good: its holder hears silence in place of a radio station's breaks. */
passes?: { good: (pass: string) => Promise<boolean> };
/** Auto stations: run a sync with these settings (and keep them), or read them. */
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
/** The public stations other servers list (guide peers); absent when there are none. */
Expand Down Expand Up @@ -325,14 +329,17 @@ 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();
return true;
}
// 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);
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/station-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/stations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading