nixamp 0.45.4 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/break-silence.d.ts +27 -0
- package/dist/break-silence.js +57 -0
- package/dist/library.js +1 -0
- package/dist/listen-pass.d.ts +29 -0
- package/dist/listen-pass.js +93 -0
- package/dist/server.js +31 -0
- package/dist/station-page.js +2 -1
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +9 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -1
- package/dist/stations.d.ts +2 -0
- package/dist/stations.js +5 -1
- package/package.json +3 -3
- package/src/break-silence.ts +65 -0
- package/src/library.ts +1 -0
- package/src/listen-pass.ts +104 -0
- package/src/server.ts +33 -0
- package/src/station-page.ts +2 -1
- package/src/station-routes.ts +11 -1
- package/src/station-usage.ts +1 -1
- package/src/stations.ts +5 -1
- package/web/dist/assets/{ar-xvLZ3ofn.js → ar-BlY2yL5Y.js} +1 -1
- package/web/dist/assets/{cs-C7-uUQA9.js → cs-BimNH85J.js} +1 -1
- package/web/dist/assets/{da-cQhhi2Pg.js → da-B3IBhGVZ.js} +1 -1
- package/web/dist/assets/{de-8hoez3Qy.js → de-CH-pHqCQ.js} +1 -1
- package/web/dist/assets/{es-CTtqkNRz.js → es-BI5-s8Fs.js} +1 -1
- package/web/dist/assets/{fi-CS_j3H0z.js → fi-BQL91oQN.js} +1 -1
- package/web/dist/assets/{fr-Da2_xiCp.js → fr-CkzjINxM.js} +1 -1
- package/web/dist/assets/{hi-CwJVR1xQ.js → hi-DN8JNzbF.js} +1 -1
- package/web/dist/assets/{hu-Cj6afP-k.js → hu-Cgi1qJdP.js} +1 -1
- package/web/dist/assets/{id-C8y8jQm9.js → id-7NL6kLrr.js} +1 -1
- package/web/dist/assets/{it-COCm7P7w.js → it-jg9ppgTc.js} +1 -1
- package/web/dist/assets/main-BXsE3ZHI.js +22 -0
- package/web/dist/assets/{nl-aoLtknAt.js → nl-Cyz8HiXs.js} +1 -1
- package/web/dist/assets/{ru-B8WDQTnf.js → ru-BzkaVvIz.js} +1 -1
- package/web/dist/assets/{station-Djxdr3MD.js → station-BhCxDaWb.js} +1 -1
- package/web/dist/assets/{sv-DIy6tmT6.js → sv-CqTcD1de.js} +1 -1
- package/web/dist/assets/{uk-C_QEqSEn.js → uk-CvHTXcNv.js} +1 -1
- package/web/dist/assets/{vi-sShSpYrp.js → vi-BYQnyW2Q.js} +1 -1
- package/web/dist/assets/{zh-jrOPMKzL.js → zh-VSRcivxg.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
- package/web/dist/assets/main-srxxjfXp.js +0 -22
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence in place of a break, for one listener of a station.
|
|
3
|
+
*
|
|
4
|
+
* A station's breaks are in its one stream (playlist-input.ts), so a listener
|
|
5
|
+
* with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
|
|
6
|
+
* only the spot: while the channel says a break is on, what goes to them is
|
|
7
|
+
* MP3 silence lasting as long as the bytes it stands in for. Audio only; a
|
|
8
|
+
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
|
+
* everybody's.
|
|
10
|
+
*
|
|
11
|
+
* The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
|
|
12
|
+
* resampled to) with empty side information: four header bytes and zeros,
|
|
13
|
+
* which every decoder plays as nothing.
|
|
14
|
+
*/
|
|
15
|
+
/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
|
|
16
|
+
export declare function silentFrame(kbps: number): Buffer;
|
|
17
|
+
export interface Writer {
|
|
18
|
+
write: (chunk: Buffer) => boolean;
|
|
19
|
+
end: () => void;
|
|
20
|
+
pending?: () => number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A writer that sends silence instead of whatever is written while `inBreak()`
|
|
24
|
+
* says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
|
|
25
|
+
* seconds.
|
|
26
|
+
*/
|
|
27
|
+
export declare function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence in place of a break, for one listener of a station.
|
|
3
|
+
*
|
|
4
|
+
* A station's breaks are in its one stream (playlist-input.ts), so a listener
|
|
5
|
+
* with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
|
|
6
|
+
* only the spot: while the channel says a break is on, what goes to them is
|
|
7
|
+
* MP3 silence lasting as long as the bytes it stands in for. Audio only; a
|
|
8
|
+
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
|
+
* everybody's.
|
|
10
|
+
*
|
|
11
|
+
* The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
|
|
12
|
+
* resampled to) with empty side information: four header bytes and zeros,
|
|
13
|
+
* which every decoder plays as nothing.
|
|
14
|
+
*/
|
|
15
|
+
const SAMPLE_RATE = 48_000;
|
|
16
|
+
const FRAME_SECONDS = 1152 / SAMPLE_RATE;
|
|
17
|
+
/** MPEG-1 Layer III bitrate index by kbps. */
|
|
18
|
+
const BITRATE_INDEX = {
|
|
19
|
+
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,
|
|
20
|
+
};
|
|
21
|
+
/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
|
|
22
|
+
export function silentFrame(kbps) {
|
|
23
|
+
const rate = BITRATE_INDEX[kbps] ? kbps : 128;
|
|
24
|
+
const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE);
|
|
25
|
+
// Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo.
|
|
26
|
+
frame[0] = 0xff;
|
|
27
|
+
frame[1] = 0xfb;
|
|
28
|
+
frame[2] = (BITRATE_INDEX[rate] << 4) | (0b01 << 2);
|
|
29
|
+
frame[3] = 0x00;
|
|
30
|
+
return frame;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A writer that sends silence instead of whatever is written while `inBreak()`
|
|
34
|
+
* says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
|
|
35
|
+
* seconds.
|
|
36
|
+
*/
|
|
37
|
+
export function quietDuringBreaks(out, kbps, inBreak) {
|
|
38
|
+
const frame = silentFrame(kbps);
|
|
39
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
40
|
+
let owed = 0;
|
|
41
|
+
return {
|
|
42
|
+
write: (chunk) => {
|
|
43
|
+
if (!inBreak()) {
|
|
44
|
+
owed = 0;
|
|
45
|
+
return out.write(chunk);
|
|
46
|
+
}
|
|
47
|
+
owed += chunk.length / bytesPerSecond;
|
|
48
|
+
const frames = Math.floor(owed / FRAME_SECONDS);
|
|
49
|
+
if (frames === 0)
|
|
50
|
+
return true;
|
|
51
|
+
owed -= frames * FRAME_SECONDS;
|
|
52
|
+
return out.write(Buffer.concat(Array.from({ length: frames }, () => frame)));
|
|
53
|
+
},
|
|
54
|
+
end: () => out.end(),
|
|
55
|
+
...(out.pending ? { pending: out.pending } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/library.js
CHANGED
|
@@ -37,6 +37,7 @@ export function readConfig() {
|
|
|
37
37
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
|
|
38
38
|
...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] } : {}),
|
|
39
39
|
...(Array.isArray(record["guidePeers"]) ? { guidePeers: record["guidePeers"].filter((one) => typeof one === "string") } : {}),
|
|
40
|
+
...(typeof record["watchBase"] === "string" ? { watchBase: record["watchBase"] } : {}),
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
catch {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** How long a pass is good for. The page asks again on every load. */
|
|
2
|
+
export declare const PASS_TTL_SECONDS: number;
|
|
3
|
+
/** Where a pass rides on a stream link (an audio element sends no headers). */
|
|
4
|
+
export declare const PASS_PARAM = "pass";
|
|
5
|
+
/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
|
|
6
|
+
export declare function listenPassSecret(env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
export declare function mintListenPass(secret: string, nowMs?: number, ttlSeconds?: number): {
|
|
8
|
+
pass: string;
|
|
9
|
+
expires: number;
|
|
10
|
+
};
|
|
11
|
+
/** The expiry of a good pass in seconds, or 0. */
|
|
12
|
+
export declare function checkListenPass(secret: string, pass: string, nowMs?: number): number;
|
|
13
|
+
export interface VerifierOptions {
|
|
14
|
+
/** nixamp.com, which signed the pass. */
|
|
15
|
+
site: string;
|
|
16
|
+
/** Set on nixamp.com itself, which checks its own passes without asking. */
|
|
17
|
+
secret?: string;
|
|
18
|
+
fetcher?: typeof fetch;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
|
|
22
|
+
export declare class ListenPassVerifier {
|
|
23
|
+
private readonly options;
|
|
24
|
+
/** pass -> whether it is good, and until when that answer stands. */
|
|
25
|
+
private readonly known;
|
|
26
|
+
constructor(options: VerifierOptions);
|
|
27
|
+
private now;
|
|
28
|
+
good(pass: string): Promise<boolean>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listen pass: what a signed-in nixamp.com listener carries to a station
|
|
3
|
+
* on another server, so its breaks go quiet for them.
|
|
4
|
+
*
|
|
5
|
+
* Breaks are in the stream (playlist-input.ts), one stream for every
|
|
6
|
+
* listener, so a station cannot leave a spot out for one person: it can only
|
|
7
|
+
* send that person silence while everybody else hears it. To do even that it
|
|
8
|
+
* has to know who is signed in, and a station on server1 holds no accounts.
|
|
9
|
+
* nixamp.com does, so nixamp.com signs a short pass for a signed-in account
|
|
10
|
+
* (GET /api/v1/me/pass, credentialed from any *.nixamp.com page) and the
|
|
11
|
+
* station asks nixamp.com whether a pass is good (GET
|
|
12
|
+
* /api/v1/listen-pass/check), remembering the answer.
|
|
13
|
+
*
|
|
14
|
+
* A pass is `v1.<expiry seconds>.<signature>` and nothing else: no account
|
|
15
|
+
* id, because a station has no use for one and a link gets pasted around.
|
|
16
|
+
* Anybody holding a fresh pass hears no breaks until it expires, which is the
|
|
17
|
+
* same thing a signed-in listener sharing their screen would give away.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
20
|
+
/** How long a pass is good for. The page asks again on every load. */
|
|
21
|
+
export const PASS_TTL_SECONDS = 24 * 60 * 60;
|
|
22
|
+
/** Where a pass rides on a stream link (an audio element sends no headers). */
|
|
23
|
+
export const PASS_PARAM = "pass";
|
|
24
|
+
/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
|
|
25
|
+
export function listenPassSecret(env = process.env) {
|
|
26
|
+
if (env["NIXAMP_LISTEN_PASS_SECRET"])
|
|
27
|
+
return env["NIXAMP_LISTEN_PASS_SECRET"];
|
|
28
|
+
// Derived, so nixamp.com needs no new variable: a pass is not a session.
|
|
29
|
+
const jwt = env["NIXAMP_JWT_SECRET"] ?? "";
|
|
30
|
+
return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : "";
|
|
31
|
+
}
|
|
32
|
+
function sign(secret, body) {
|
|
33
|
+
return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32);
|
|
34
|
+
}
|
|
35
|
+
export function mintListenPass(secret, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS) {
|
|
36
|
+
const expires = Math.floor(nowMs / 1000) + ttlSeconds;
|
|
37
|
+
const body = `v1.${expires}`;
|
|
38
|
+
return { pass: `${body}.${sign(secret, body)}`, expires };
|
|
39
|
+
}
|
|
40
|
+
/** The expiry of a good pass in seconds, or 0. */
|
|
41
|
+
export function checkListenPass(secret, pass, nowMs = Date.now()) {
|
|
42
|
+
const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass);
|
|
43
|
+
if (!secret || !match)
|
|
44
|
+
return 0;
|
|
45
|
+
const expires = Number(match[1]);
|
|
46
|
+
if (expires * 1000 <= nowMs)
|
|
47
|
+
return 0;
|
|
48
|
+
const want = Buffer.from(sign(secret, `v1.${expires}`));
|
|
49
|
+
const got = Buffer.from(match[2]);
|
|
50
|
+
return want.length === got.length && timingSafeEqual(want, got) ? expires : 0;
|
|
51
|
+
}
|
|
52
|
+
/** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */
|
|
53
|
+
const REFUSED_MS = 5 * 60 * 1000;
|
|
54
|
+
const MAX_REMEMBERED = 5_000;
|
|
55
|
+
/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
|
|
56
|
+
export class ListenPassVerifier {
|
|
57
|
+
options;
|
|
58
|
+
/** pass -> whether it is good, and until when that answer stands. */
|
|
59
|
+
known = new Map();
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.options = options;
|
|
62
|
+
}
|
|
63
|
+
now() {
|
|
64
|
+
return this.options.now?.() ?? Date.now();
|
|
65
|
+
}
|
|
66
|
+
async good(pass) {
|
|
67
|
+
if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass))
|
|
68
|
+
return false;
|
|
69
|
+
if (this.options.secret)
|
|
70
|
+
return checkListenPass(this.options.secret, pass, this.now()) > 0;
|
|
71
|
+
const seen = this.known.get(pass);
|
|
72
|
+
if (seen && seen.until > this.now())
|
|
73
|
+
return seen.good;
|
|
74
|
+
let expires = 0;
|
|
75
|
+
try {
|
|
76
|
+
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) });
|
|
77
|
+
// nixamp.com down is not a refusal: nothing is remembered, and this
|
|
78
|
+
// listener hears the break this time.
|
|
79
|
+
if (answer.status >= 500)
|
|
80
|
+
return false;
|
|
81
|
+
const body = (await answer.json().catch(() => ({})));
|
|
82
|
+
expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (this.known.size >= MAX_REMEMBERED)
|
|
88
|
+
this.known.clear();
|
|
89
|
+
const good = expires * 1000 > this.now();
|
|
90
|
+
this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS });
|
|
91
|
+
return good;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -101,6 +101,7 @@ import { EventWriter } from "./event-writer.js";
|
|
|
101
101
|
import { handleLiveApi } from "./live-api.js";
|
|
102
102
|
import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
|
|
103
103
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
|
|
104
|
+
import { ListenPassVerifier, PASS_PARAM, checkListenPass, listenPassSecret, mintListenPass } from "./listen-pass.js";
|
|
104
105
|
import { Layouts } from "./layouts.js";
|
|
105
106
|
import { Rooms } from "./rooms.js";
|
|
106
107
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
|
|
@@ -1246,6 +1247,9 @@ export function isSignInPath(path) {
|
|
|
1246
1247
|
path === "/api/v1/servers" ||
|
|
1247
1248
|
path.startsWith("/api/v1/servers/") ||
|
|
1248
1249
|
path === "/api/v1/me/handle" ||
|
|
1250
|
+
// A listen pass is the session's to ask for and anybody's to check.
|
|
1251
|
+
path === "/api/v1/me/pass" ||
|
|
1252
|
+
path === "/api/v1/listen-pass/check" ||
|
|
1249
1253
|
path === "/api/v1/me/profile" ||
|
|
1250
1254
|
path.startsWith("/api/v1/me/profile/") ||
|
|
1251
1255
|
// A stored photo is public: it is on every class the host runs.
|
|
@@ -1478,6 +1482,12 @@ export function createHandler(engine, options) {
|
|
|
1478
1482
|
// away, which are the same thing as far as "is it still going" goes.
|
|
1479
1483
|
response.once("close", () => tracker.close(id));
|
|
1480
1484
|
};
|
|
1485
|
+
// Signs listen passes where accounts live (nixamp.com); "" everywhere else.
|
|
1486
|
+
const listenPassKey = listenPassSecret();
|
|
1487
|
+
const listenPasses = new ListenPassVerifier({
|
|
1488
|
+
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
1489
|
+
...(listenPassKey ? { secret: listenPassKey } : {}),
|
|
1490
|
+
});
|
|
1481
1491
|
return async function handle(request, response) {
|
|
1482
1492
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1483
1493
|
const path = url.pathname;
|
|
@@ -2521,6 +2531,7 @@ export function createHandler(engine, options) {
|
|
|
2521
2531
|
...(options.stations.cover ? { cover: options.stations.cover } : {}),
|
|
2522
2532
|
...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
|
|
2523
2533
|
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
2534
|
+
passes: listenPasses,
|
|
2524
2535
|
...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
|
|
2525
2536
|
...(options.stations.rooms ? {
|
|
2526
2537
|
rooms: () => roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
|
|
@@ -3758,6 +3769,26 @@ export function createHandler(engine, options) {
|
|
|
3758
3769
|
});
|
|
3759
3770
|
return;
|
|
3760
3771
|
}
|
|
3772
|
+
// A listen pass (listen-pass.ts): a station page on any *.nixamp.com server
|
|
3773
|
+
// asks for one with this site's cookie, which is same-site there, so a
|
|
3774
|
+
// signed-in listener hears no breaks. Credentialed, so never "*".
|
|
3775
|
+
if (path === "/api/v1/me/pass" && request.method === "GET" && options.accounts && listenPassKey) {
|
|
3776
|
+
const origin = String(request.headers.origin ?? "");
|
|
3777
|
+
const cors = /^https:\/\/([a-z0-9-]+\.)*nixamp\.com(:\d+)?$/.test(origin)
|
|
3778
|
+
? { "access-control-allow-origin": origin, "access-control-allow-credentials": "true", vary: "origin" }
|
|
3779
|
+
: {};
|
|
3780
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
3781
|
+
const body = who === null ? { error: "not signed in" } : mintListenPass(listenPassKey);
|
|
3782
|
+
response.writeHead(who === null ? 401 : 200, { ...cors, "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
3783
|
+
response.end(JSON.stringify(body));
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
// Asked by a station on another server about a pass it was handed.
|
|
3787
|
+
if (path === "/api/v1/listen-pass/check" && request.method === "GET" && listenPassKey) {
|
|
3788
|
+
const expires = checkListenPass(listenPassKey, url.searchParams.get(PASS_PARAM) ?? "");
|
|
3789
|
+
json(response, 200, expires > 0 ? { ok: true, expires } : { ok: false });
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3761
3792
|
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
3762
3793
|
const handles = options.handles;
|
|
3763
3794
|
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
package/dist/station-page.js
CHANGED
|
@@ -188,7 +188,8 @@ export const STATION_APP_CSP = [
|
|
|
188
188
|
"media-src 'self' blob:",
|
|
189
189
|
"worker-src 'self' blob:",
|
|
190
190
|
"img-src 'self' data: blob:",
|
|
191
|
-
|
|
191
|
+
// nixamp.com for a listen pass, so a signed-in listener's breaks go quiet.
|
|
192
|
+
"connect-src 'self' https://nixamp.com",
|
|
192
193
|
"base-uri 'none'",
|
|
193
194
|
"form-action 'none'",
|
|
194
195
|
"frame-ancestors 'none'",
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export interface StationContext {
|
|
|
76
76
|
status: () => unknown;
|
|
77
77
|
publish: (station: Station) => Promise<PublishReport>;
|
|
78
78
|
};
|
|
79
|
+
/** Whether a listen pass is good: its holder hears silence in place of a radio station's breaks. */
|
|
80
|
+
passes?: {
|
|
81
|
+
good: (pass: string) => Promise<boolean>;
|
|
82
|
+
};
|
|
79
83
|
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
80
84
|
auto?: {
|
|
81
85
|
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
package/dist/station-routes.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { alertsFor } from "./station-metering.js";
|
|
2
2
|
import { aiFromTags } from "./track-facts.js";
|
|
3
|
+
import { quietDuringBreaks } from "./break-silence.js";
|
|
4
|
+
import { PASS_PARAM } from "./listen-pass.js";
|
|
3
5
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
4
6
|
/** The three things a station's link opens. */
|
|
5
7
|
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
@@ -244,6 +246,9 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
244
246
|
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
245
247
|
return true;
|
|
246
248
|
}
|
|
249
|
+
// Signed in at nixamp.com: the breaks go quiet. Radio only (break-silence.ts).
|
|
250
|
+
const pass = url.searchParams.get(PASS_PARAM) ?? "";
|
|
251
|
+
const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
|
|
247
252
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
248
253
|
if (method === "HEAD") {
|
|
249
254
|
response.end();
|
|
@@ -251,7 +256,7 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
251
256
|
}
|
|
252
257
|
// Every byte that goes out through the link is counted against the station.
|
|
253
258
|
const usage = ctx.usage;
|
|
254
|
-
const
|
|
259
|
+
const metered = usage
|
|
255
260
|
? {
|
|
256
261
|
write: (chunk) => {
|
|
257
262
|
usage.delivered(station.id, chunk.length);
|
|
@@ -261,6 +266,9 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
261
266
|
pending: () => response.writableLength,
|
|
262
267
|
}
|
|
263
268
|
: response;
|
|
269
|
+
const counted = spared
|
|
270
|
+
? quietDuringBreaks(metered, station.bitrate, () => ctx.channels.info(channelId)?.playlistAt === -1)
|
|
271
|
+
: metered;
|
|
264
272
|
const detach = ctx.channels.listen(channelId, counted);
|
|
265
273
|
if (detach === null) {
|
|
266
274
|
response.end();
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n 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)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const USAGE = `nixamp station — make a collection into a station
|
|
3
3
|
|
|
4
4
|
nixamp station list
|
|
5
|
-
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]
|
|
5
|
+
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)]
|
|
6
6
|
nixamp station show ID
|
|
7
7
|
nixamp station status ID
|
|
8
8
|
nixamp station start ID
|
package/dist/stations.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface Station {
|
|
|
45
45
|
/** Times an item may fail in a row before rotation leaves it out. */
|
|
46
46
|
export declare const EXCLUDE_AFTER = 3;
|
|
47
47
|
export declare const DEFAULT_BITRATE = 128;
|
|
48
|
+
/** Seconds between breaks on a new station that did not say. */
|
|
49
|
+
export declare const DEFAULT_BREAKS_EVERY = 600;
|
|
48
50
|
export declare const BITRATES: number[];
|
|
49
51
|
/** A station's channel id. */
|
|
50
52
|
export declare function channelOf(id: string): string;
|
package/dist/stations.js
CHANGED
|
@@ -26,6 +26,8 @@ export const RIGHTS_BASES = ["owned", "permission", "licensed", "public-domain"]
|
|
|
26
26
|
/** Times an item may fail in a row before rotation leaves it out. */
|
|
27
27
|
export const EXCLUDE_AFTER = 3;
|
|
28
28
|
export const DEFAULT_BITRATE = 128;
|
|
29
|
+
/** Seconds between breaks on a new station that did not say. */
|
|
30
|
+
export const DEFAULT_BREAKS_EVERY = 600;
|
|
29
31
|
export const BITRATES = [64, 96, 128, 160, 192, 256, 320];
|
|
30
32
|
const MAX_ITEMS = 20_000;
|
|
31
33
|
const MAX_NAME = 120;
|
|
@@ -179,7 +181,9 @@ export class StationStore {
|
|
|
179
181
|
listenToken: newToken(),
|
|
180
182
|
bitrate: this.bitrateFrom(input.bitrate),
|
|
181
183
|
video: input.video === true,
|
|
182
|
-
|
|
184
|
+
// On unless asked for none: a station is free to listen to, and the
|
|
185
|
+
// spot is what pays for it. Signed-in listeners hear silence instead.
|
|
186
|
+
breaksEvery: input.breaksEvery === undefined ? DEFAULT_BREAKS_EVERY : this.breaksFrom(input.breaksEvery),
|
|
183
187
|
auto: "",
|
|
184
188
|
rights: null,
|
|
185
189
|
owner,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@profullstack/notifications": "^0.1.0",
|
|
52
52
|
"@profullstack/synconfig": "^0.1.2",
|
|
53
53
|
"@profullstack/throttle": "0.2.2",
|
|
54
|
-
"@profullstack/x402-gateway": "^0.
|
|
54
|
+
"@profullstack/x402-gateway": "^0.10.1",
|
|
55
55
|
"acme-client": "5.4.0",
|
|
56
56
|
"pg": "^8.23.0"
|
|
57
57
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
"overrides": {
|
|
85
85
|
"https-proxy-agent": "7.0.6",
|
|
86
86
|
"tslib": "2.8.1",
|
|
87
|
-
"@profullstack/x402-gateway": "^0.
|
|
87
|
+
"@profullstack/x402-gateway": "^0.10.1"
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence in place of a break, for one listener of a station.
|
|
3
|
+
*
|
|
4
|
+
* A station's breaks are in its one stream (playlist-input.ts), so a listener
|
|
5
|
+
* with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
|
|
6
|
+
* only the spot: while the channel says a break is on, what goes to them is
|
|
7
|
+
* MP3 silence lasting as long as the bytes it stands in for. Audio only; a
|
|
8
|
+
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
|
+
* everybody's.
|
|
10
|
+
*
|
|
11
|
+
* The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
|
|
12
|
+
* resampled to) with empty side information: four header bytes and zeros,
|
|
13
|
+
* which every decoder plays as nothing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SAMPLE_RATE = 48_000;
|
|
17
|
+
const FRAME_SECONDS = 1152 / SAMPLE_RATE;
|
|
18
|
+
/** MPEG-1 Layer III bitrate index by kbps. */
|
|
19
|
+
const BITRATE_INDEX: Record<number, number> = {
|
|
20
|
+
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,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
|
|
24
|
+
export function silentFrame(kbps: number): Buffer {
|
|
25
|
+
const rate = BITRATE_INDEX[kbps] ? kbps : 128;
|
|
26
|
+
const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE);
|
|
27
|
+
// Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo.
|
|
28
|
+
frame[0] = 0xff;
|
|
29
|
+
frame[1] = 0xfb;
|
|
30
|
+
frame[2] = (BITRATE_INDEX[rate]! << 4) | (0b01 << 2);
|
|
31
|
+
frame[3] = 0x00;
|
|
32
|
+
return frame;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Writer {
|
|
36
|
+
write: (chunk: Buffer) => boolean;
|
|
37
|
+
end: () => void;
|
|
38
|
+
pending?: () => number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A writer that sends silence instead of whatever is written while `inBreak()`
|
|
43
|
+
* says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
|
|
44
|
+
* seconds.
|
|
45
|
+
*/
|
|
46
|
+
export function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer {
|
|
47
|
+
const frame = silentFrame(kbps);
|
|
48
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
49
|
+
let owed = 0;
|
|
50
|
+
return {
|
|
51
|
+
write: (chunk: Buffer): boolean => {
|
|
52
|
+
if (!inBreak()) {
|
|
53
|
+
owed = 0;
|
|
54
|
+
return out.write(chunk);
|
|
55
|
+
}
|
|
56
|
+
owed += chunk.length / bytesPerSecond;
|
|
57
|
+
const frames = Math.floor(owed / FRAME_SECONDS);
|
|
58
|
+
if (frames === 0) return true;
|
|
59
|
+
owed -= frames * FRAME_SECONDS;
|
|
60
|
+
return out.write(Buffer.concat(Array.from({ length: frames }, () => frame)));
|
|
61
|
+
},
|
|
62
|
+
end: () => out.end(),
|
|
63
|
+
...(out.pending ? { pending: out.pending } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -59,6 +59,7 @@ export function readConfig(): Config {
|
|
|
59
59
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
60
60
|
...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] as Config["guideAds"] } : {}),
|
|
61
61
|
...(Array.isArray(record["guidePeers"]) ? { guidePeers: (record["guidePeers"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
62
|
+
...(typeof record["watchBase"] === "string" ? { watchBase: record["watchBase"] } : {}),
|
|
62
63
|
};
|
|
63
64
|
} catch {
|
|
64
65
|
return {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listen pass: what a signed-in nixamp.com listener carries to a station
|
|
3
|
+
* on another server, so its breaks go quiet for them.
|
|
4
|
+
*
|
|
5
|
+
* Breaks are in the stream (playlist-input.ts), one stream for every
|
|
6
|
+
* listener, so a station cannot leave a spot out for one person: it can only
|
|
7
|
+
* send that person silence while everybody else hears it. To do even that it
|
|
8
|
+
* has to know who is signed in, and a station on server1 holds no accounts.
|
|
9
|
+
* nixamp.com does, so nixamp.com signs a short pass for a signed-in account
|
|
10
|
+
* (GET /api/v1/me/pass, credentialed from any *.nixamp.com page) and the
|
|
11
|
+
* station asks nixamp.com whether a pass is good (GET
|
|
12
|
+
* /api/v1/listen-pass/check), remembering the answer.
|
|
13
|
+
*
|
|
14
|
+
* A pass is `v1.<expiry seconds>.<signature>` and nothing else: no account
|
|
15
|
+
* id, because a station has no use for one and a link gets pasted around.
|
|
16
|
+
* Anybody holding a fresh pass hears no breaks until it expires, which is the
|
|
17
|
+
* same thing a signed-in listener sharing their screen would give away.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
/** How long a pass is good for. The page asks again on every load. */
|
|
22
|
+
export const PASS_TTL_SECONDS = 24 * 60 * 60;
|
|
23
|
+
|
|
24
|
+
/** Where a pass rides on a stream link (an audio element sends no headers). */
|
|
25
|
+
export const PASS_PARAM = "pass";
|
|
26
|
+
|
|
27
|
+
/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
|
|
28
|
+
export function listenPassSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
29
|
+
if (env["NIXAMP_LISTEN_PASS_SECRET"]) return env["NIXAMP_LISTEN_PASS_SECRET"];
|
|
30
|
+
// Derived, so nixamp.com needs no new variable: a pass is not a session.
|
|
31
|
+
const jwt = env["NIXAMP_JWT_SECRET"] ?? "";
|
|
32
|
+
return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sign(secret: string, body: string): string {
|
|
36
|
+
return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function mintListenPass(secret: string, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS): { pass: string; expires: number } {
|
|
40
|
+
const expires = Math.floor(nowMs / 1000) + ttlSeconds;
|
|
41
|
+
const body = `v1.${expires}`;
|
|
42
|
+
return { pass: `${body}.${sign(secret, body)}`, expires };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The expiry of a good pass in seconds, or 0. */
|
|
46
|
+
export function checkListenPass(secret: string, pass: string, nowMs = Date.now()): number {
|
|
47
|
+
const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass);
|
|
48
|
+
if (!secret || !match) return 0;
|
|
49
|
+
const expires = Number(match[1]);
|
|
50
|
+
if (expires * 1000 <= nowMs) return 0;
|
|
51
|
+
const want = Buffer.from(sign(secret, `v1.${expires}`));
|
|
52
|
+
const got = Buffer.from(match[2]!);
|
|
53
|
+
return want.length === got.length && timingSafeEqual(want, got) ? expires : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface VerifierOptions {
|
|
57
|
+
/** nixamp.com, which signed the pass. */
|
|
58
|
+
site: string;
|
|
59
|
+
/** Set on nixamp.com itself, which checks its own passes without asking. */
|
|
60
|
+
secret?: string;
|
|
61
|
+
fetcher?: typeof fetch;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */
|
|
66
|
+
const REFUSED_MS = 5 * 60 * 1000;
|
|
67
|
+
const MAX_REMEMBERED = 5_000;
|
|
68
|
+
|
|
69
|
+
/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
|
|
70
|
+
export class ListenPassVerifier {
|
|
71
|
+
/** pass -> whether it is good, and until when that answer stands. */
|
|
72
|
+
private readonly known = new Map<string, { good: boolean; until: number }>();
|
|
73
|
+
|
|
74
|
+
constructor(private readonly options: VerifierOptions) {}
|
|
75
|
+
|
|
76
|
+
private now(): number {
|
|
77
|
+
return this.options.now?.() ?? Date.now();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async good(pass: string): Promise<boolean> {
|
|
81
|
+
if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass)) return false;
|
|
82
|
+
if (this.options.secret) return checkListenPass(this.options.secret, pass, this.now()) > 0;
|
|
83
|
+
const seen = this.known.get(pass);
|
|
84
|
+
if (seen && seen.until > this.now()) return seen.good;
|
|
85
|
+
let expires = 0;
|
|
86
|
+
try {
|
|
87
|
+
const answer = await (this.options.fetcher ?? fetch)(
|
|
88
|
+
`${this.options.site.replace(/\/+$/, "")}/api/v1/listen-pass/check?${PASS_PARAM}=${encodeURIComponent(pass)}`,
|
|
89
|
+
{ signal: AbortSignal.timeout(2_000) },
|
|
90
|
+
);
|
|
91
|
+
// nixamp.com down is not a refusal: nothing is remembered, and this
|
|
92
|
+
// listener hears the break this time.
|
|
93
|
+
if (answer.status >= 500) return false;
|
|
94
|
+
const body = (await answer.json().catch(() => ({}))) as { ok?: unknown; expires?: unknown };
|
|
95
|
+
expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (this.known.size >= MAX_REMEMBERED) this.known.clear();
|
|
100
|
+
const good = expires * 1000 > this.now();
|
|
101
|
+
this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS });
|
|
102
|
+
return good;
|
|
103
|
+
}
|
|
104
|
+
}
|