nixamp 0.57.0 → 0.58.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/guide-rooms.d.ts +42 -0
- package/dist/guide-rooms.js +157 -7
- package/dist/label-profiles.js +2 -2
- package/dist/oauth.d.ts +36 -3
- package/dist/oauth.js +107 -7
- package/dist/radio-pages.d.ts +2 -2
- package/dist/radio-pages.js +4 -4
- package/dist/server.js +59 -6
- package/package.json +1 -1
- package/src/guide-rooms.ts +182 -8
- package/src/label-profiles.ts +2 -2
- package/src/oauth.ts +119 -6
- package/src/radio-pages.ts +4 -4
- package/src/server.ts +60 -5
- package/web/dist/assets/{ar-d05d_7Df.js → ar-C8G_vLmh.js} +1 -1
- package/web/dist/assets/{cs-CclrNwa7.js → cs-7bYMjFS2.js} +1 -1
- package/web/dist/assets/{da-C30KTGl1.js → da-DHY5Csp_.js} +1 -1
- package/web/dist/assets/{de-D2A2RQwi.js → de-YNBoxnLa.js} +1 -1
- package/web/dist/assets/{es-BXFx0WZ5.js → es-9F9kBzzh.js} +1 -1
- package/web/dist/assets/{fi-DPA3S8og.js → fi-BqpzsfBb.js} +1 -1
- package/web/dist/assets/{fr-D_0wnr4h.js → fr-BKQXRwFk.js} +1 -1
- package/web/dist/assets/{hi-EfiCQnb9.js → hi-CGNIGyR-.js} +1 -1
- package/web/dist/assets/{hu-CK5rnxKO.js → hu-anYPYX8d.js} +1 -1
- package/web/dist/assets/{id-BgTV_4UK.js → id-BW3GasRn.js} +1 -1
- package/web/dist/assets/{it-DGefapXn.js → it-C7w2DZ3R.js} +1 -1
- package/web/dist/assets/{main-C-tWhTWo.js → main-YkIgU0VD.js} +2 -2
- package/web/dist/assets/{nl-Cyst29Bp.js → nl-BNrkTQI2.js} +1 -1
- package/web/dist/assets/{radio-CtAne8M1.css → radio-Cmg6GPN2.css} +1 -1
- package/web/dist/assets/{radio-yD_-qpS7.js → radio-DV3h4YZS.js} +1 -1
- package/web/dist/assets/{ru-D8G6Wynm.js → ru-CnKOo1uk.js} +1 -1
- package/web/dist/assets/station-M7G4NiE3.js +3 -0
- package/web/dist/assets/{sv-aYnDAFwz.js → sv-BsknKdtx.js} +1 -1
- package/web/dist/assets/{uk-BDtew7z3.js → uk-u_igTh6D.js} +1 -1
- package/web/dist/assets/{vi-CRlDn2Xh.js → vi-DH0ApTua.js} +1 -1
- package/web/dist/assets/watch-Brz-RgjN.js +1 -0
- package/web/dist/assets/{zh-BCAIjeQq.js → zh-Bcnk2nIV.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +2 -2
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +23 -23
- package/web/dist/watch.html +1 -1
- package/web/dist/assets/station-D6sd4DqP.js +0 -3
- package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
package/dist/guide-rooms.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export declare function roomAddress(input: string): {
|
|
|
32
32
|
/** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
|
|
33
33
|
export declare function watchBase(fallback: string, env?: Record<string, string | undefined>): string;
|
|
34
34
|
export declare const watchPage: (base: string, play: string) => string;
|
|
35
|
+
/** A room's page under its name, on this server. */
|
|
36
|
+
export declare const roomPage: (base: string, slug: string) => string;
|
|
37
|
+
export declare const ROOM_PAGE: RegExp;
|
|
38
|
+
export declare const ROOM_SUB: RegExp;
|
|
39
|
+
export declare function isRoomPath(path: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
|
|
42
|
+
* first, so a name stays with the room that had it; a second one is -2.
|
|
43
|
+
*/
|
|
44
|
+
export declare function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom>;
|
|
45
|
+
/** A room's name here, or "". */
|
|
46
|
+
export declare function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string;
|
|
35
47
|
export declare class RoomStore {
|
|
36
48
|
private readonly file;
|
|
37
49
|
private rooms;
|
|
@@ -53,6 +65,9 @@ export interface RoomState {
|
|
|
53
65
|
title: string;
|
|
54
66
|
artist: string;
|
|
55
67
|
} | null;
|
|
68
|
+
/** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
|
|
69
|
+
folder?: boolean;
|
|
70
|
+
album?: string;
|
|
56
71
|
}
|
|
57
72
|
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
58
73
|
export declare class RoomProbe {
|
|
@@ -89,3 +104,30 @@ export interface RoomContext {
|
|
|
89
104
|
}
|
|
90
105
|
/** Answers /api/v1/guide/rooms; false when the path is not one. */
|
|
91
106
|
export declare function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean>;
|
|
107
|
+
export interface RoomProxyContext {
|
|
108
|
+
store: RoomStore;
|
|
109
|
+
probe: RoomProbe;
|
|
110
|
+
/** Why an address may not be fetched from this machine, or "" (as the probe's). */
|
|
111
|
+
refuse: (link: string) => Promise<string>;
|
|
112
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
113
|
+
fetcher?: typeof fetch;
|
|
114
|
+
}
|
|
115
|
+
/** What /room/<name>/state answers: the room as its page shows it, no address or key. */
|
|
116
|
+
export interface RoomView {
|
|
117
|
+
name: string;
|
|
118
|
+
kind: "audio" | "video";
|
|
119
|
+
live: boolean;
|
|
120
|
+
folder: boolean;
|
|
121
|
+
hls: boolean;
|
|
122
|
+
title: string;
|
|
123
|
+
artist: string;
|
|
124
|
+
album: string;
|
|
125
|
+
}
|
|
126
|
+
/** The room's origin and key, gone from a text answer (a playlist, an error). */
|
|
127
|
+
export declare function roomRewriter(play: string, slug: string): (text: string) => string;
|
|
128
|
+
/**
|
|
129
|
+
* Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
|
|
130
|
+
* server with its key; false when the path is not one. The page itself is
|
|
131
|
+
* the caller's (it is the built watch page).
|
|
132
|
+
*/
|
|
133
|
+
export declare function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean>;
|
package/dist/guide-rooms.js
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
* a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
|
|
6
6
|
* or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
|
|
7
7
|
* a rights basis, as a station is published; the guide lists it beside the
|
|
8
|
-
* stations
|
|
9
|
-
*
|
|
8
|
+
* stations; its card opens /room/<name>, which plays it as a film or as
|
|
9
|
+
* radio, whichever it carries.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* /room/<name> is the room under a readable name on this server, which asks
|
|
12
|
+
* the room's own server for it with the key held here: the page, its state,
|
|
13
|
+
* the stream and HLS all come from this origin, so no page names the machine
|
|
14
|
+
* the room is on or carries its key. The old /watch?play=<address> links
|
|
15
|
+
* still play, straight from the room's server.
|
|
13
16
|
*/
|
|
14
17
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
18
|
import { join } from "node:path";
|
|
16
19
|
import { randomBytes } from "node:crypto";
|
|
17
20
|
import { readConfig } from "./library.js";
|
|
21
|
+
import { slugify } from "./radio-slug.js";
|
|
18
22
|
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
19
23
|
export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
|
|
20
24
|
/** Rooms one member may have on the guide at once, and rooms on it in all. */
|
|
@@ -62,6 +66,36 @@ export function watchBase(fallback, env = process.env) {
|
|
|
62
66
|
return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
|
|
63
67
|
}
|
|
64
68
|
export const watchPage = (base, play) => `${base}/watch?play=${encodeURIComponent(play)}`;
|
|
69
|
+
/** A room's page under its name, on this server. */
|
|
70
|
+
export const roomPage = (base, slug) => `${base}/room/${slug}`;
|
|
71
|
+
export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
|
|
72
|
+
export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
|
|
73
|
+
export function isRoomPath(path) {
|
|
74
|
+
return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
|
|
78
|
+
* first, so a name stays with the room that had it; a second one is -2.
|
|
79
|
+
*/
|
|
80
|
+
export function roomSlugs(rooms) {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
|
|
83
|
+
for (const room of sorted) {
|
|
84
|
+
const base = slugify(room.name) || "room";
|
|
85
|
+
let slug = base;
|
|
86
|
+
for (let n = 2; out.has(slug); n += 1)
|
|
87
|
+
slug = `${base}-${n}`;
|
|
88
|
+
out.set(slug, room);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** A room's name here, or "". */
|
|
93
|
+
export function roomSlugOf(slugs, id) {
|
|
94
|
+
for (const [slug, one] of slugs)
|
|
95
|
+
if (one.id === id)
|
|
96
|
+
return slug;
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
65
99
|
export class RoomStore {
|
|
66
100
|
file;
|
|
67
101
|
rooms = [];
|
|
@@ -149,6 +183,8 @@ export class RoomProbe {
|
|
|
149
183
|
name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
|
|
150
184
|
kind: row.kind === "video" ? "video" : "audio",
|
|
151
185
|
nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
|
|
186
|
+
folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
|
|
187
|
+
album: String(tags?.album ?? "").slice(0, 200),
|
|
152
188
|
};
|
|
153
189
|
}
|
|
154
190
|
// The live stream: on the air when it answers with media.
|
|
@@ -160,7 +196,7 @@ export class RoomProbe {
|
|
|
160
196
|
controller.abort();
|
|
161
197
|
if (!answer.ok)
|
|
162
198
|
return null;
|
|
163
|
-
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
|
|
199
|
+
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
|
|
164
200
|
}
|
|
165
201
|
finally {
|
|
166
202
|
clearTimeout(timer);
|
|
@@ -169,6 +205,7 @@ export class RoomProbe {
|
|
|
169
205
|
}
|
|
170
206
|
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
171
207
|
export async function roomEntries(store, probe, base) {
|
|
208
|
+
const slugs = roomSlugs(store.list());
|
|
172
209
|
return Promise.all(store.list().map(async (room) => {
|
|
173
210
|
const state = await probe.state(room.play);
|
|
174
211
|
return {
|
|
@@ -177,7 +214,7 @@ export async function roomEntries(store, probe, base) {
|
|
|
177
214
|
kind: state?.kind ?? room.kind,
|
|
178
215
|
group: "rooms",
|
|
179
216
|
state: state?.live ? "live" : "offline",
|
|
180
|
-
page:
|
|
217
|
+
page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
|
|
181
218
|
nowPlaying: state?.nowPlaying ?? null,
|
|
182
219
|
};
|
|
183
220
|
}));
|
|
@@ -245,7 +282,7 @@ export async function handleRooms(request, response, path, ctx) {
|
|
|
245
282
|
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
|
|
246
283
|
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
247
284
|
const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
|
|
248
|
-
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page:
|
|
285
|
+
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: roomPage(ctx.base, roomSlugOf(roomSlugs(ctx.store.list()), room.id) || room.id) });
|
|
249
286
|
return true;
|
|
250
287
|
}
|
|
251
288
|
const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
@@ -262,3 +299,116 @@ export async function handleRooms(request, response, path, ctx) {
|
|
|
262
299
|
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
263
300
|
return true;
|
|
264
301
|
}
|
|
302
|
+
/** The room's origin and key, gone from a text answer (a playlist, an error). */
|
|
303
|
+
export function roomRewriter(play, slug) {
|
|
304
|
+
const address = roomAddress(play);
|
|
305
|
+
if (!address)
|
|
306
|
+
return (text) => text;
|
|
307
|
+
const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
|
|
308
|
+
return (text) => text
|
|
309
|
+
.split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
|
|
310
|
+
.split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
|
|
311
|
+
.split(address.origin).join("")
|
|
312
|
+
.split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
|
|
313
|
+
.split(address.key).join("");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
|
|
317
|
+
* server with its key; false when the path is not one. The page itself is
|
|
318
|
+
* the caller's (it is the built watch page).
|
|
319
|
+
*/
|
|
320
|
+
export async function handleRoomProxy(request, response, path, ctx) {
|
|
321
|
+
const sub = ROOM_SUB.exec(path);
|
|
322
|
+
if (!sub)
|
|
323
|
+
return false;
|
|
324
|
+
const method = request.method ?? "GET";
|
|
325
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
326
|
+
ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const slug = sub[1];
|
|
330
|
+
const room = roomSlugs(ctx.store.list()).get(slug);
|
|
331
|
+
const address = room ? roomAddress(room.play) : null;
|
|
332
|
+
if (!room || !address) {
|
|
333
|
+
ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const what = sub[2];
|
|
337
|
+
if (what === "state") {
|
|
338
|
+
const state = await ctx.probe.state(room.play);
|
|
339
|
+
if (!state) {
|
|
340
|
+
ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
const view = {
|
|
344
|
+
name: room.name || state.name,
|
|
345
|
+
kind: state.kind,
|
|
346
|
+
live: state.live,
|
|
347
|
+
folder: state.folder === true,
|
|
348
|
+
hls: address.channel !== null,
|
|
349
|
+
title: state.nowPlaying?.title ?? "",
|
|
350
|
+
artist: state.nowPlaying?.artist ?? "",
|
|
351
|
+
album: state.album ?? "",
|
|
352
|
+
};
|
|
353
|
+
response.setHeader("cache-control", "no-store");
|
|
354
|
+
ctx.json(response, 200, view);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
let upstream;
|
|
358
|
+
if (what === "stream")
|
|
359
|
+
upstream = address.play;
|
|
360
|
+
else {
|
|
361
|
+
if (address.channel === null) {
|
|
362
|
+
ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
|
|
366
|
+
}
|
|
367
|
+
if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
|
|
368
|
+
ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
const stop = new AbortController();
|
|
372
|
+
response.on("close", () => stop.abort());
|
|
373
|
+
const answer = await (ctx.fetcher ?? fetch)(upstream, {
|
|
374
|
+
method, signal: stop.signal, redirect: "error",
|
|
375
|
+
headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
|
|
376
|
+
}).catch(() => null);
|
|
377
|
+
if (!answer) {
|
|
378
|
+
ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const type = answer.headers.get("content-type") ?? "";
|
|
382
|
+
const text = /json|mpegurl|^text\//i.test(type);
|
|
383
|
+
const headers = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
|
|
384
|
+
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
|
|
385
|
+
const value = answer.headers.get(name);
|
|
386
|
+
if (value && !(text && name === "content-length"))
|
|
387
|
+
headers[name] = value;
|
|
388
|
+
}
|
|
389
|
+
if (text) {
|
|
390
|
+
// A playlist (or an error) is small: read whole, the room's address and key taken out.
|
|
391
|
+
const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
|
|
392
|
+
response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
|
|
393
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
response.writeHead(answer.status, headers);
|
|
397
|
+
if (!answer.body || method === "HEAD") {
|
|
398
|
+
response.end();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
for await (const chunk of answer.body) {
|
|
403
|
+
if (response.destroyed)
|
|
404
|
+
break;
|
|
405
|
+
if (!response.write(chunk))
|
|
406
|
+
await new Promise((done) => { response.once("drain", done); response.once("close", done); });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// The viewer left, or the room's server did.
|
|
411
|
+
}
|
|
412
|
+
response.end();
|
|
413
|
+
return true;
|
|
414
|
+
}
|
package/dist/label-profiles.js
CHANGED
|
@@ -98,7 +98,7 @@ export function bandMarkdown(band) {
|
|
|
98
98
|
const MEMBERS_SYSTEM = [
|
|
99
99
|
"You cast the members of a fictional band (or the one person behind a solo act) for an AI record label.",
|
|
100
100
|
"Everyone is invented: never a real person, never a lookalike of one. Give each a full name, their role, and a look described",
|
|
101
|
-
"precisely enough that an illustrator could draw the same person every time
|
|
101
|
+
"precisely enough that an illustrator could draw the same person every time. Start every look with their gender and age (\"woman, 24, ...\"), then face, skin tone, hair, build, signature outfit and accessories.",
|
|
102
102
|
"Answer as JSON: {\"members\": [{\"name\": \"\", \"role\": \"\", \"look\": \"at most 300 characters\", \"voice\": \"how they sing or play, at most 150 characters\",",
|
|
103
103
|
"\"personality\": \"at most 150 characters\", \"influences\": \"styles and eras, never named artists, at most 120 characters\", \"bio\": \"one or two sentences\"}]}.",
|
|
104
104
|
].join(" ");
|
|
@@ -143,7 +143,7 @@ export function castLine(members) {
|
|
|
143
143
|
}
|
|
144
144
|
/** The portrait prompt for a member: the same person every time it is used as a reference. */
|
|
145
145
|
export function portraitPrompt(member, band) {
|
|
146
|
-
return `Photographic portrait of ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
146
|
+
return `Photographic portrait of ${member.name}: ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
147
147
|
}
|
|
148
148
|
/** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
|
|
149
149
|
export async function writeMembers(folder, members, band, draw) {
|
package/dist/oauth.d.ts
CHANGED
|
@@ -13,22 +13,41 @@ export interface Provider {
|
|
|
13
13
|
scope: string;
|
|
14
14
|
/** Turn the provider's access token into an address it stands behind. */
|
|
15
15
|
identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
|
|
16
|
+
/** OAuth 2.1: the round trip carries a PKCE S256 challenge and the exchange its verifier. */
|
|
17
|
+
pkce?: boolean;
|
|
16
18
|
}
|
|
17
19
|
/** Who the provider says this is. `subject` is stable when an address is not. */
|
|
18
20
|
export interface Identity {
|
|
19
21
|
provider: string;
|
|
20
22
|
subject: string;
|
|
21
23
|
email: string;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the provider verified the address. Absent means yes (GitHub and
|
|
26
|
+
* Google are only asked for verified ones). An unverified one never links
|
|
27
|
+
* to an existing account and is never the new account's address.
|
|
28
|
+
*/
|
|
29
|
+
verified?: boolean;
|
|
22
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
|
|
33
|
+
* code with PKCE S256 and a client secret. CoinPay does not verify the
|
|
34
|
+
* address it reports, so a CoinPay identity is matched by its `sub` only.
|
|
35
|
+
*/
|
|
36
|
+
export declare function coinpayProvider(clientId: string, clientSecret: string, base?: string): Provider;
|
|
37
|
+
/** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
|
|
38
|
+
export declare function pkcePair(): {
|
|
39
|
+
verifier: string;
|
|
40
|
+
challenge: string;
|
|
41
|
+
};
|
|
23
42
|
export declare function githubProvider(clientId: string, clientSecret: string): Provider;
|
|
24
43
|
export declare function googleProvider(clientId: string, clientSecret: string): Provider;
|
|
25
44
|
/** Whichever providers this deployment has been given both halves of. */
|
|
26
45
|
export declare function providersFrom(env: Record<string, string | undefined>): Provider[];
|
|
27
46
|
export declare function redirectUri(site: string, provider: Provider): string;
|
|
28
47
|
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
29
|
-
export declare function authorizeUrl(provider: Provider, site: string, state: string): string;
|
|
48
|
+
export declare function authorizeUrl(provider: Provider, site: string, state: string, challenge?: string): string;
|
|
30
49
|
/** Swap the code for an access token. Empty string means the provider refused. */
|
|
31
|
-
export declare function exchangeCode(provider: Provider, code: string, site: string, send?: typeof fetch): Promise<string>;
|
|
50
|
+
export declare function exchangeCode(provider: Provider, code: string, site: string, send?: typeof fetch, verifier?: string): Promise<string>;
|
|
32
51
|
/** The slice of the auth module's storage adapter that identities need. */
|
|
33
52
|
export interface Users {
|
|
34
53
|
getUserByEmail(email: string): Promise<{
|
|
@@ -60,6 +79,8 @@ export declare class Identities {
|
|
|
60
79
|
constructor(db: Queryable, users: Users);
|
|
61
80
|
private ensure;
|
|
62
81
|
resolve(identity: Identity): Promise<Account | null>;
|
|
82
|
+
/** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
|
|
83
|
+
private resolveUnverified;
|
|
63
84
|
}
|
|
64
85
|
/**
|
|
65
86
|
* What a round trip to a provider is for.
|
|
@@ -74,7 +95,19 @@ export interface Pending {
|
|
|
74
95
|
/** Set when this round trip is approving a terminal rather than a browser. */
|
|
75
96
|
userCode: string;
|
|
76
97
|
createdAt: number;
|
|
98
|
+
/** The PKCE verifier, for a provider that takes one. */
|
|
99
|
+
verifier?: string;
|
|
100
|
+
/** Where the browser goes once signed in (a path here, or an allowed site); "/" by default. */
|
|
101
|
+
redirect?: string;
|
|
77
102
|
}
|
|
103
|
+
/** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
|
|
104
|
+
export declare function signInReturnHosts(env?: Record<string, string | undefined>): string[];
|
|
105
|
+
/**
|
|
106
|
+
* Where a sign-in may send the browser afterwards: a path on this site, or a
|
|
107
|
+
* page on one of the sites that send people here to sign in (live.streaming).
|
|
108
|
+
* Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
|
|
109
|
+
*/
|
|
110
|
+
export declare function safeRedirect(value: unknown, allowedHosts?: string[]): string;
|
|
78
111
|
/** A state is only ever open for the length of one sign-in. */
|
|
79
112
|
export declare const STATE_TTL_MS = 600000;
|
|
80
113
|
export declare class SignIn {
|
|
@@ -91,7 +124,7 @@ export declare class SignIn {
|
|
|
91
124
|
}[];
|
|
92
125
|
provider(id: unknown): Provider | null;
|
|
93
126
|
/** Start a round trip, and answer the URL the browser should go to. */
|
|
94
|
-
begin(provider: Provider, userCode?: string): string;
|
|
127
|
+
begin(provider: Provider, userCode?: string, redirect?: string): string;
|
|
95
128
|
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
|
96
129
|
claim(state: unknown): Pending | null;
|
|
97
130
|
private sweep;
|
package/dist/oauth.js
CHANGED
|
@@ -16,7 +16,37 @@
|
|
|
16
16
|
* address, and both are asked. Without that check, anyone who can add an
|
|
17
17
|
* unverified address at a provider could claim somebody else's nixamp account.
|
|
18
18
|
*/
|
|
19
|
-
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
/**
|
|
21
|
+
* CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
|
|
22
|
+
* code with PKCE S256 and a client secret. CoinPay does not verify the
|
|
23
|
+
* address it reports, so a CoinPay identity is matched by its `sub` only.
|
|
24
|
+
*/
|
|
25
|
+
export function coinpayProvider(clientId, clientSecret, base = "https://coinpayportal.com") {
|
|
26
|
+
return {
|
|
27
|
+
id: "coinpay",
|
|
28
|
+
name: "CoinPay",
|
|
29
|
+
clientId,
|
|
30
|
+
clientSecret,
|
|
31
|
+
authorizeUrl: `${base}/api/oauth/authorize`,
|
|
32
|
+
tokenUrl: `${base}/api/oauth/token`,
|
|
33
|
+
scope: "openid profile email",
|
|
34
|
+
pkce: true,
|
|
35
|
+
async identify(accessToken, send) {
|
|
36
|
+
const who = await readJson(await send(`${base}/api/oauth/userinfo`, { headers: { authorization: `Bearer ${accessToken}`, ...AGENT } }));
|
|
37
|
+
const subject = typeof who["sub"] === "string" ? who["sub"] : "";
|
|
38
|
+
if (!subject)
|
|
39
|
+
return null;
|
|
40
|
+
const email = typeof who["email"] === "string" ? who["email"].toLowerCase() : "";
|
|
41
|
+
return { provider: "coinpay", subject, email, verified: who["email_verified"] === true };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
|
|
46
|
+
export function pkcePair() {
|
|
47
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
48
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
49
|
+
}
|
|
20
50
|
const AGENT = { "user-agent": "nixamp" };
|
|
21
51
|
async function readJson(answer) {
|
|
22
52
|
if (!answer.ok)
|
|
@@ -86,23 +116,30 @@ export function providersFrom(env) {
|
|
|
86
116
|
if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
|
|
87
117
|
found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
|
|
88
118
|
}
|
|
119
|
+
if (env["COINPAY_OAUTH_CLIENT_ID"] && env["COINPAY_OAUTH_CLIENT_SECRET"]) {
|
|
120
|
+
found.push(coinpayProvider(env["COINPAY_OAUTH_CLIENT_ID"], env["COINPAY_OAUTH_CLIENT_SECRET"], env["COINPAY_OAUTH_BASE"] || undefined));
|
|
121
|
+
}
|
|
89
122
|
return found;
|
|
90
123
|
}
|
|
91
124
|
export function redirectUri(site, provider) {
|
|
92
125
|
return `${site.replace(/\/+$/, "")}/api/v1/${provider.id}/oauth/callback`;
|
|
93
126
|
}
|
|
94
127
|
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
95
|
-
export function authorizeUrl(provider, site, state) {
|
|
128
|
+
export function authorizeUrl(provider, site, state, challenge = "") {
|
|
96
129
|
const url = new URL(provider.authorizeUrl);
|
|
97
130
|
url.searchParams.set("client_id", provider.clientId);
|
|
98
131
|
url.searchParams.set("redirect_uri", redirectUri(site, provider));
|
|
99
132
|
url.searchParams.set("response_type", "code");
|
|
100
133
|
url.searchParams.set("scope", provider.scope);
|
|
101
134
|
url.searchParams.set("state", state);
|
|
135
|
+
if (challenge) {
|
|
136
|
+
url.searchParams.set("code_challenge", challenge);
|
|
137
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
138
|
+
}
|
|
102
139
|
return url.toString();
|
|
103
140
|
}
|
|
104
141
|
/** Swap the code for an access token. Empty string means the provider refused. */
|
|
105
|
-
export async function exchangeCode(provider, code, site, send = fetch) {
|
|
142
|
+
export async function exchangeCode(provider, code, site, send = fetch, verifier = "") {
|
|
106
143
|
const answer = await send(provider.tokenUrl, {
|
|
107
144
|
method: "POST",
|
|
108
145
|
headers: {
|
|
@@ -118,6 +155,7 @@ export async function exchangeCode(provider, code, site, send = fetch) {
|
|
|
118
155
|
code,
|
|
119
156
|
redirect_uri: redirectUri(site, provider),
|
|
120
157
|
grant_type: "authorization_code",
|
|
158
|
+
...(verifier ? { code_verifier: verifier } : {}),
|
|
121
159
|
}).toString(),
|
|
122
160
|
});
|
|
123
161
|
const body = await readJson(answer);
|
|
@@ -156,7 +194,15 @@ export class Identities {
|
|
|
156
194
|
await this.ready;
|
|
157
195
|
}
|
|
158
196
|
async resolve(identity) {
|
|
159
|
-
if (!identity.subject
|
|
197
|
+
if (!identity.subject)
|
|
198
|
+
return null;
|
|
199
|
+
// An address the provider did not verify (CoinPay's) is a hint, not a
|
|
200
|
+
// claim: the account is found by subject alone, and a new one gets an
|
|
201
|
+
// address nobody can own, so purchases kept by email are never reached
|
|
202
|
+
// through it.
|
|
203
|
+
if (identity.verified === false)
|
|
204
|
+
return this.resolveUnverified(identity);
|
|
205
|
+
if (!identity.email)
|
|
160
206
|
return null;
|
|
161
207
|
await this.ensure();
|
|
162
208
|
// Already linked. The subject is what is matched, not the address: people
|
|
@@ -200,6 +246,59 @@ export class Identities {
|
|
|
200
246
|
ON CONFLICT (provider, subject) DO UPDATE SET email = EXCLUDED.email`, [identity.provider, identity.subject, account.id, identity.email]);
|
|
201
247
|
return account;
|
|
202
248
|
}
|
|
249
|
+
/** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
|
|
250
|
+
async resolveUnverified(identity) {
|
|
251
|
+
await this.ensure();
|
|
252
|
+
const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [identity.provider, identity.subject]);
|
|
253
|
+
const linked = rows[0];
|
|
254
|
+
const placeholder = `${identity.provider}-${identity.subject.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 60)}@${identity.provider}.invalid`;
|
|
255
|
+
if (linked) {
|
|
256
|
+
const id = String(linked["user_id"] ?? "");
|
|
257
|
+
return id ? { id, email: String(linked["email"] ?? "") || placeholder } : null;
|
|
258
|
+
}
|
|
259
|
+
const existing = await this.users.getUserByEmail(placeholder);
|
|
260
|
+
const account = existing?.id
|
|
261
|
+
? { id: existing.id, email: existing.email ?? placeholder }
|
|
262
|
+
: await (async () => {
|
|
263
|
+
const made = await this.users.createUser({
|
|
264
|
+
email: placeholder,
|
|
265
|
+
password: null,
|
|
266
|
+
emailVerified: false,
|
|
267
|
+
profile: { signedUpWith: identity.provider, ...(identity.email ? { providerEmail: identity.email } : {}) },
|
|
268
|
+
});
|
|
269
|
+
return { id: String(made.id ?? ""), email: made.email ?? placeholder };
|
|
270
|
+
})();
|
|
271
|
+
if (!account.id)
|
|
272
|
+
return null;
|
|
273
|
+
await this.db.query(`INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
|
|
274
|
+
ON CONFLICT (provider, subject) DO NOTHING`, [identity.provider, identity.subject, account.id, placeholder]);
|
|
275
|
+
return account;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
|
|
279
|
+
export function signInReturnHosts(env = process.env) {
|
|
280
|
+
const set = (env["NIXAMP_SIGNIN_RETURN_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean);
|
|
281
|
+
return set.length ? set : ["live.streaming", "live.streaming.pit.world", "bittorrented.com", "backtoschool.help"];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Where a sign-in may send the browser afterwards: a path on this site, or a
|
|
285
|
+
* page on one of the sites that send people here to sign in (live.streaming).
|
|
286
|
+
* Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
|
|
287
|
+
*/
|
|
288
|
+
export function safeRedirect(value, allowedHosts = []) {
|
|
289
|
+
if (typeof value !== "string" || value === "")
|
|
290
|
+
return "/";
|
|
291
|
+
if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\"))
|
|
292
|
+
return value.slice(0, 500);
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(value);
|
|
295
|
+
if (url.protocol === "https:" && allowedHosts.includes(url.hostname))
|
|
296
|
+
return url.href.slice(0, 500);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// not a URL
|
|
300
|
+
}
|
|
301
|
+
return "/";
|
|
203
302
|
}
|
|
204
303
|
/** A state is only ever open for the length of one sign-in. */
|
|
205
304
|
export const STATE_TTL_MS = 600_000;
|
|
@@ -223,11 +322,12 @@ export class SignIn {
|
|
|
223
322
|
return this.providers.find((candidate) => candidate.id === id) ?? null;
|
|
224
323
|
}
|
|
225
324
|
/** Start a round trip, and answer the URL the browser should go to. */
|
|
226
|
-
begin(provider, userCode = "") {
|
|
325
|
+
begin(provider, userCode = "", redirect = "/") {
|
|
227
326
|
this.sweep();
|
|
228
327
|
const state = randomBytes(24).toString("base64url");
|
|
229
|
-
|
|
230
|
-
|
|
328
|
+
const pair = provider.pkce ? pkcePair() : null;
|
|
329
|
+
this.states.set(state, { provider: provider.id, userCode, createdAt: this.now(), redirect, ...(pair ? { verifier: pair.verifier } : {}) });
|
|
330
|
+
return authorizeUrl(provider, this.site, state, pair?.challenge ?? "");
|
|
231
331
|
}
|
|
232
332
|
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
|
233
333
|
claim(state) {
|
package/dist/radio-pages.d.ts
CHANGED
|
@@ -188,9 +188,9 @@ export interface StationFace {
|
|
|
188
188
|
description?: string;
|
|
189
189
|
video?: boolean;
|
|
190
190
|
}
|
|
191
|
-
export declare function stationHead(station: StationFace, slug: string, base: string): string;
|
|
191
|
+
export declare function stationHead(station: StationFace, slug: string, base: string, path?: string): string;
|
|
192
192
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
193
|
-
export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string;
|
|
193
|
+
export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string;
|
|
194
194
|
/** A station another server lists on this guide (a guide peer), as /radio reaches it. */
|
|
195
195
|
export interface PeerRadio {
|
|
196
196
|
origin: string;
|
package/dist/radio-pages.js
CHANGED
|
@@ -388,8 +388,8 @@ export function renderAlbum(catalogue, album, ctx) {
|
|
|
388
388
|
export function renderMissing(what, base, nav) {
|
|
389
389
|
return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav, `<h1>No ${escape(what)} by that name</h1><p class="lead">Nothing by that name is on the air here now. <a href="/radio">See every station</a>.</p>`);
|
|
390
390
|
}
|
|
391
|
-
export function stationHead(station, slug, base) {
|
|
392
|
-
const url = `${base}
|
|
391
|
+
export function stationHead(station, slug, base, path = `/radio/${slug}`) {
|
|
392
|
+
const url = `${base}${path}`;
|
|
393
393
|
const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
|
|
394
394
|
return `<title>${escape(station.name)} · live.streaming</title>
|
|
395
395
|
<meta name="description" content="${escape(description)}" />
|
|
@@ -403,9 +403,9 @@ export function stationHead(station, slug, base) {
|
|
|
403
403
|
<style>${NAV_STYLE}</style>`;
|
|
404
404
|
}
|
|
405
405
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
406
|
-
export function asRadioPage(html, station, slug, base, nav) {
|
|
406
|
+
export function asRadioPage(html, station, slug, base, nav, path) {
|
|
407
407
|
return html
|
|
408
|
-
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
|
|
408
|
+
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
|
|
409
409
|
.replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
|
|
410
410
|
.replace(/<body>/, `<body>\n${nav}`);
|
|
411
411
|
}
|