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.
Files changed (43) hide show
  1. package/dist/guide-rooms.d.ts +42 -0
  2. package/dist/guide-rooms.js +157 -7
  3. package/dist/label-profiles.js +2 -2
  4. package/dist/oauth.d.ts +36 -3
  5. package/dist/oauth.js +107 -7
  6. package/dist/radio-pages.d.ts +2 -2
  7. package/dist/radio-pages.js +4 -4
  8. package/dist/server.js +59 -6
  9. package/package.json +1 -1
  10. package/src/guide-rooms.ts +182 -8
  11. package/src/label-profiles.ts +2 -2
  12. package/src/oauth.ts +119 -6
  13. package/src/radio-pages.ts +4 -4
  14. package/src/server.ts +60 -5
  15. package/web/dist/assets/{ar-d05d_7Df.js → ar-C8G_vLmh.js} +1 -1
  16. package/web/dist/assets/{cs-CclrNwa7.js → cs-7bYMjFS2.js} +1 -1
  17. package/web/dist/assets/{da-C30KTGl1.js → da-DHY5Csp_.js} +1 -1
  18. package/web/dist/assets/{de-D2A2RQwi.js → de-YNBoxnLa.js} +1 -1
  19. package/web/dist/assets/{es-BXFx0WZ5.js → es-9F9kBzzh.js} +1 -1
  20. package/web/dist/assets/{fi-DPA3S8og.js → fi-BqpzsfBb.js} +1 -1
  21. package/web/dist/assets/{fr-D_0wnr4h.js → fr-BKQXRwFk.js} +1 -1
  22. package/web/dist/assets/{hi-EfiCQnb9.js → hi-CGNIGyR-.js} +1 -1
  23. package/web/dist/assets/{hu-CK5rnxKO.js → hu-anYPYX8d.js} +1 -1
  24. package/web/dist/assets/{id-BgTV_4UK.js → id-BW3GasRn.js} +1 -1
  25. package/web/dist/assets/{it-DGefapXn.js → it-C7w2DZ3R.js} +1 -1
  26. package/web/dist/assets/{main-C-tWhTWo.js → main-YkIgU0VD.js} +2 -2
  27. package/web/dist/assets/{nl-Cyst29Bp.js → nl-BNrkTQI2.js} +1 -1
  28. package/web/dist/assets/{radio-CtAne8M1.css → radio-Cmg6GPN2.css} +1 -1
  29. package/web/dist/assets/{radio-yD_-qpS7.js → radio-DV3h4YZS.js} +1 -1
  30. package/web/dist/assets/{ru-D8G6Wynm.js → ru-CnKOo1uk.js} +1 -1
  31. package/web/dist/assets/station-M7G4NiE3.js +3 -0
  32. package/web/dist/assets/{sv-aYnDAFwz.js → sv-BsknKdtx.js} +1 -1
  33. package/web/dist/assets/{uk-BDtew7z3.js → uk-u_igTh6D.js} +1 -1
  34. package/web/dist/assets/{vi-CRlDn2Xh.js → vi-DH0ApTua.js} +1 -1
  35. package/web/dist/assets/watch-Brz-RgjN.js +1 -0
  36. package/web/dist/assets/{zh-BCAIjeQq.js → zh-Bcnk2nIV.js} +1 -1
  37. package/web/dist/index.html +1 -1
  38. package/web/dist/radio.html +2 -2
  39. package/web/dist/station.html +1 -1
  40. package/web/dist/sw.js +23 -23
  41. package/web/dist/watch.html +1 -1
  42. package/web/dist/assets/station-D6sd4DqP.js +0 -3
  43. package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
package/dist/server.js CHANGED
@@ -40,7 +40,7 @@ import { NixampLinks, nixampExchange } from "./nixamp-link.js";
40
40
  import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.js";
41
41
  import { DeviceGrants } from "./device.js";
42
42
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
43
- import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
43
+ import { deviceDonePage, devicePage, exchangeCode, providersFrom, safeRedirect, signInReturnHosts, signInFailedPage, SignIn, } from "./oauth.js";
44
44
  import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
45
45
  import { handleOAuthApi, oauthApiPath } from "./oauth-api.js";
46
46
  import { WatchParties } from "./watch-party.js";
@@ -73,7 +73,7 @@ import { LyricsFinder } from "./lyrics.js";
73
73
  import { LyricsPublisher } from "./lyrics-publish.js";
74
74
  import { aiKey } from "./ai-keys.js";
75
75
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
76
- import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
76
+ import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomProbe, roomEntries, roomSlugOf, roomSlugs, RoomStore, watchBase } from "./guide-rooms.js";
77
77
  import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
78
78
  import { withPitFooter } from "./pit-footer.js";
79
79
  import { handleFollowPage } from "./follow-page.js";
@@ -2317,6 +2317,8 @@ export function createHandler(engine, options) {
2317
2317
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2318
2318
  // A live room's page: the room's own key travels in ?play=, checked by its server.
2319
2319
  !(path === "/watch" && Boolean(options.stations?.rooms)) &&
2320
+ // A live room under its name here, asked of its server with the key held here (guide-rooms.ts).
2321
+ !(isRoomPath(path) && Boolean(options.stations?.rooms)) &&
2320
2322
  // Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
2321
2323
  !((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
2322
2324
  // On demand: a file's page and the guide's list of them (guide-vods.ts); the file's own key travels in its play address.
@@ -2546,7 +2548,56 @@ export function createHandler(engine, options) {
2546
2548
  // A live room's page: /watch?play=<a nixamp server's channel or live
2547
2549
  // stream address>. The built page plays it in @profullstack/player, as a
2548
2550
  // film or as radio; nothing about the room passes through here.
2551
+ if (options.stations?.rooms && isRoomPath(path)) {
2552
+ const rooms = options.stations.rooms;
2553
+ if (await handleRoomProxy(request, response, path, { store: rooms.store, probe: rooms.probe, refuse: (link) => refusedDestination(link), json }))
2554
+ return;
2555
+ const asked = ROOM_PAGE.exec(path)[1];
2556
+ const slugs = roomSlugs(rooms.store.list());
2557
+ const room = slugs.get(asked);
2558
+ if (!room) {
2559
+ // A room's id finds its name: /room/<id> goes to /room/<name>.
2560
+ const named = roomSlugOf(slugs, asked);
2561
+ if (named) {
2562
+ response.writeHead(301, { location: `/room/${named}`, "cache-control": "public, max-age=300" });
2563
+ response.end();
2564
+ return;
2565
+ }
2566
+ }
2567
+ if (request.method !== "GET" && request.method !== "HEAD") {
2568
+ json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
2569
+ return;
2570
+ }
2571
+ const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html"), "utf8") : null;
2572
+ if (!built) {
2573
+ json(response, 404, { error: "this server was started without its web pages" });
2574
+ return;
2575
+ }
2576
+ const base = radioBaseFor(request, options.stations.publicBase?.() ?? "");
2577
+ const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide", "live");
2578
+ const face = room ? { name: room.name, description: room.note || `${room.name}, live now on live.streaming.`, video: room.kind === "video" } : { name: "No such room" };
2579
+ response.writeHead(room ? 200 : 404, {
2580
+ "content-type": "text/html; charset=utf-8",
2581
+ "cache-control": "no-cache",
2582
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2583
+ "referrer-policy": "strict-origin-when-cross-origin",
2584
+ "x-content-type-options": "nosniff",
2585
+ });
2586
+ response.end(request.method === "HEAD" ? undefined : withPageAd(asRadioPage(built, face, asked, base, nav, `/room/${asked}`), guideAds()));
2587
+ return;
2588
+ }
2549
2589
  if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2590
+ // A room published here plays under its name: the old link keeps working and lands there.
2591
+ const play = roomAddress(url.searchParams.get("play") ?? "");
2592
+ if (play) {
2593
+ const slugs = roomSlugs(options.stations.rooms.store.list());
2594
+ const published = [...slugs].find(([, one]) => one.play === play.play)?.[0];
2595
+ if (published) {
2596
+ response.writeHead(302, { location: `/room/${published}`, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2597
+ response.end();
2598
+ return;
2599
+ }
2600
+ }
2550
2601
  const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
2551
2602
  if (!built) {
2552
2603
  json(response, 404, { error: "this server was started without its web pages" });
@@ -4509,7 +4560,9 @@ export function createHandler(engine, options) {
4509
4560
  // A terminal can link straight here with the code it is showing, which
4510
4561
  // is one hop shorter than the page for somebody who followed the link.
4511
4562
  const grant = signIn.device.find(url.searchParams.get("device") ?? "");
4512
- response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "") });
4563
+ // Back where the visit was going: a page here, or a site that sends people here to sign in.
4564
+ const redirect = safeRedirect(url.searchParams.get("redirect"), signInReturnHosts());
4565
+ response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "", redirect) });
4513
4566
  response.end();
4514
4567
  return;
4515
4568
  }
@@ -4525,10 +4578,10 @@ export function createHandler(engine, options) {
4525
4578
  html(response, 400, signInFailedPage(url.searchParams.get("error") ?? "The provider sent no code."));
4526
4579
  return;
4527
4580
  }
4528
- const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
4581
+ const access = await exchangeCode(provider, code, signIn.site, fetch, pending.verifier ?? "").catch(() => "");
4529
4582
  const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
4530
4583
  if (identity === null) {
4531
- html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
4584
+ html(response, 401, signInFailedPage(provider.pkce ? `${provider.name} did not finish the sign-in. Try it again.` : `${provider.name} did not confirm a verified email address.`));
4532
4585
  return;
4533
4586
  }
4534
4587
  const result = await accounts.signInWith(identity);
@@ -4548,7 +4601,7 @@ export function createHandler(engine, options) {
4548
4601
  }
4549
4602
  response.writeHead(302, {
4550
4603
  "set-cookie": sessionCookie(result.token, secure),
4551
- location: "/",
4604
+ location: pending.redirect || "/",
4552
4605
  });
4553
4606
  response.end();
4554
4607
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.57.0",
3
+ "version": "0.58.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",
@@ -5,11 +5,14 @@
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, and its card opens /watch?play=<that address>, which plays it as
9
- * a film or as radio, whichever it carries.
8
+ * stations; its card opens /room/<name>, which plays it as a film or as
9
+ * radio, whichever it carries.
10
10
  *
11
- * Nothing here plays anything: the room's own server does, and this only asks
12
- * it, now and then, whether the room is on the air and what is on it.
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";
@@ -17,6 +20,7 @@ import { randomBytes } from "node:crypto";
17
20
  import type { IncomingMessage, ServerResponse } from "node:http";
18
21
  import { readConfig } from "./library.ts";
19
22
  import type { GuideEntry } from "./station-guide.ts";
23
+ import { slugify } from "./radio-slug.ts";
20
24
 
21
25
  /** Why a room may be broadcast, the same five reasons a station gives. */
22
26
  export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
@@ -74,6 +78,38 @@ export function watchBase(fallback: string, env: Record<string, string | undefin
74
78
 
75
79
  export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
76
80
 
81
+ /** A room's page under its name, on this server. */
82
+ export const roomPage = (base: string, slug: string): string => `${base}/room/${slug}`;
83
+
84
+ export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
85
+ export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
86
+
87
+ export function isRoomPath(path: string): boolean {
88
+ return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
89
+ }
90
+
91
+ /**
92
+ * The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
93
+ * first, so a name stays with the room that had it; a second one is -2.
94
+ */
95
+ export function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom> {
96
+ const out = new Map<string, GuideRoom>();
97
+ const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
98
+ for (const room of sorted) {
99
+ const base = slugify(room.name) || "room";
100
+ let slug = base;
101
+ for (let n = 2; out.has(slug); n += 1) slug = `${base}-${n}`;
102
+ out.set(slug, room);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** A room's name here, or "". */
108
+ export function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string {
109
+ for (const [slug, one] of slugs) if (one.id === id) return slug;
110
+ return "";
111
+ }
112
+
77
113
  export class RoomStore {
78
114
  private rooms: GuideRoom[] = [];
79
115
  constructor(private readonly file: string) {
@@ -128,6 +164,9 @@ export interface RoomState {
128
164
  name: string;
129
165
  kind: "audio" | "video";
130
166
  nowPlaying: { title: string; artist: string } | null;
167
+ /** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
168
+ folder?: boolean;
169
+ album?: string;
131
170
  }
132
171
 
133
172
  interface ChannelRow {
@@ -135,7 +174,8 @@ interface ChannelRow {
135
174
  name?: string;
136
175
  kind?: string;
137
176
  live?: boolean;
138
- codecs?: { tags?: { title?: string; artist?: string } };
177
+ playlist?: unknown[];
178
+ codecs?: { tags?: { title?: string; artist?: string; album?: string } };
139
179
  }
140
180
 
141
181
  /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
@@ -175,6 +215,8 @@ export class RoomProbe {
175
215
  name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
176
216
  kind: row.kind === "video" ? "video" : "audio",
177
217
  nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
218
+ folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
219
+ album: String(tags?.album ?? "").slice(0, 200),
178
220
  };
179
221
  }
180
222
  // The live stream: on the air when it answers with media.
@@ -185,7 +227,7 @@ export class RoomProbe {
185
227
  const type = answer.headers.get("content-type") ?? "";
186
228
  controller.abort();
187
229
  if (!answer.ok) return null;
188
- return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
230
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
189
231
  } finally {
190
232
  clearTimeout(timer);
191
233
  }
@@ -194,6 +236,7 @@ export class RoomProbe {
194
236
 
195
237
  /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
196
238
  export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
239
+ const slugs = roomSlugs(store.list());
197
240
  return Promise.all(store.list().map(async (room) => {
198
241
  const state = await probe.state(room.play);
199
242
  return {
@@ -202,7 +245,7 @@ export async function roomEntries(store: RoomStore, probe: RoomProbe, base: stri
202
245
  kind: state?.kind ?? room.kind,
203
246
  group: "rooms" as const,
204
247
  state: state?.live ? "live" : "offline",
205
- page: watchPage(base, room.play),
248
+ page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
206
249
  nowPlaying: state?.nowPlaying ?? null,
207
250
  };
208
251
  }));
@@ -281,7 +324,7 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
281
324
  const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
282
325
  const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
283
326
  const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
284
- ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
327
+ 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) });
285
328
  return true;
286
329
  }
287
330
  const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
@@ -298,3 +341,134 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
298
341
  ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
299
342
  return true;
300
343
  }
344
+
345
+ // --- /room/<name>: the room through this server ----------------------------------
346
+
347
+ export interface RoomProxyContext {
348
+ store: RoomStore;
349
+ probe: RoomProbe;
350
+ /** Why an address may not be fetched from this machine, or "" (as the probe's). */
351
+ refuse: (link: string) => Promise<string>;
352
+ json: (response: ServerResponse, status: number, body: unknown) => void;
353
+ fetcher?: typeof fetch;
354
+ }
355
+
356
+ /** What /room/<name>/state answers: the room as its page shows it, no address or key. */
357
+ export interface RoomView {
358
+ name: string;
359
+ kind: "audio" | "video";
360
+ live: boolean;
361
+ folder: boolean;
362
+ hls: boolean;
363
+ title: string;
364
+ artist: string;
365
+ album: string;
366
+ }
367
+
368
+ /** The room's origin and key, gone from a text answer (a playlist, an error). */
369
+ export function roomRewriter(play: string, slug: string): (text: string) => string {
370
+ const address = roomAddress(play);
371
+ if (!address) return (text) => text;
372
+ const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
373
+ return (text) => text
374
+ .split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
375
+ .split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
376
+ .split(address.origin).join("")
377
+ .split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
378
+ .split(address.key).join("");
379
+ }
380
+
381
+ /**
382
+ * Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
383
+ * server with its key; false when the path is not one. The page itself is
384
+ * the caller's (it is the built watch page).
385
+ */
386
+ export async function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean> {
387
+ const sub = ROOM_SUB.exec(path);
388
+ if (!sub) return false;
389
+ const method = request.method ?? "GET";
390
+ if (method !== "GET" && method !== "HEAD") {
391
+ ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
392
+ return true;
393
+ }
394
+ const slug = sub[1]!;
395
+ const room = roomSlugs(ctx.store.list()).get(slug);
396
+ const address = room ? roomAddress(room.play) : null;
397
+ if (!room || !address) {
398
+ ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
399
+ return true;
400
+ }
401
+ const what = sub[2]!;
402
+ if (what === "state") {
403
+ const state = await ctx.probe.state(room.play);
404
+ if (!state) {
405
+ ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
406
+ return true;
407
+ }
408
+ const view: RoomView = {
409
+ name: room.name || state.name,
410
+ kind: state.kind,
411
+ live: state.live,
412
+ folder: state.folder === true,
413
+ hls: address.channel !== null,
414
+ title: state.nowPlaying?.title ?? "",
415
+ artist: state.nowPlaying?.artist ?? "",
416
+ album: state.album ?? "",
417
+ };
418
+ response.setHeader("cache-control", "no-store");
419
+ ctx.json(response, 200, view);
420
+ return true;
421
+ }
422
+ let upstream: string;
423
+ if (what === "stream") upstream = address.play;
424
+ else {
425
+ if (address.channel === null) {
426
+ ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
427
+ return true;
428
+ }
429
+ upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
430
+ }
431
+ if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
432
+ ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
433
+ return true;
434
+ }
435
+ const stop = new AbortController();
436
+ response.on("close", () => stop.abort());
437
+ const answer = await (ctx.fetcher ?? fetch)(upstream, {
438
+ method, signal: stop.signal, redirect: "error",
439
+ headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
440
+ }).catch(() => null);
441
+ if (!answer) {
442
+ ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
443
+ return true;
444
+ }
445
+ const type = answer.headers.get("content-type") ?? "";
446
+ const text = /json|mpegurl|^text\//i.test(type);
447
+ const headers: Record<string, string> = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
448
+ for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
449
+ const value = answer.headers.get(name);
450
+ if (value && !(text && name === "content-length")) headers[name] = value;
451
+ }
452
+ if (text) {
453
+ // A playlist (or an error) is small: read whole, the room's address and key taken out.
454
+ const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
455
+ response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
456
+ response.end(method === "HEAD" ? undefined : body);
457
+ return true;
458
+ }
459
+ response.writeHead(answer.status, headers);
460
+ if (!answer.body || method === "HEAD") {
461
+ response.end();
462
+ return true;
463
+ }
464
+ try {
465
+ for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
466
+ if (response.destroyed) break;
467
+ if (!response.write(chunk)) await new Promise((done) => { response.once("drain", done); response.once("close", done); });
468
+ }
469
+ } catch {
470
+ // The viewer left, or the room's server did.
471
+ }
472
+ response.end();
473
+ return true;
474
+ }
@@ -121,7 +121,7 @@ export function bandMarkdown(band: {
121
121
  const MEMBERS_SYSTEM = [
122
122
  "You cast the members of a fictional band (or the one person behind a solo act) for an AI record label.",
123
123
  "Everyone is invented: never a real person, never a lookalike of one. Give each a full name, their role, and a look described",
124
- "precisely enough that an illustrator could draw the same person every time: age range, face, skin tone, hair, build, signature outfit and accessories.",
124
+ "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.",
125
125
  "Answer as JSON: {\"members\": [{\"name\": \"\", \"role\": \"\", \"look\": \"at most 300 characters\", \"voice\": \"how they sing or play, at most 150 characters\",",
126
126
  "\"personality\": \"at most 150 characters\", \"influences\": \"styles and eras, never named artists, at most 120 characters\", \"bio\": \"one or two sentences\"}]}.",
127
127
  ].join(" ");
@@ -171,7 +171,7 @@ export function castLine(members: Member[]): string {
171
171
 
172
172
  /** The portrait prompt for a member: the same person every time it is used as a reference. */
173
173
  export function portraitPrompt(member: Member, band: { name: string; genre: string }): string {
174
- 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.`;
174
+ 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.`;
175
175
  }
176
176
 
177
177
  /** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
package/src/oauth.ts CHANGED
@@ -16,7 +16,7 @@
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
20
  import type { Account } from "./accounts.ts";
21
21
  import type { DeviceGrants } from "./device.ts";
22
22
  import type { Queryable } from "./follows.ts";
@@ -33,6 +33,8 @@ export interface Provider {
33
33
  scope: string;
34
34
  /** Turn the provider's access token into an address it stands behind. */
35
35
  identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
36
+ /** OAuth 2.1: the round trip carries a PKCE S256 challenge and the exchange its verifier. */
37
+ pkce?: boolean;
36
38
  }
37
39
 
38
40
  /** Who the provider says this is. `subject` is stable when an address is not. */
@@ -40,6 +42,43 @@ export interface Identity {
40
42
  provider: string;
41
43
  subject: string;
42
44
  email: string;
45
+ /**
46
+ * Whether the provider verified the address. Absent means yes (GitHub and
47
+ * Google are only asked for verified ones). An unverified one never links
48
+ * to an existing account and is never the new account's address.
49
+ */
50
+ verified?: boolean;
51
+ }
52
+
53
+ /**
54
+ * CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
55
+ * code with PKCE S256 and a client secret. CoinPay does not verify the
56
+ * address it reports, so a CoinPay identity is matched by its `sub` only.
57
+ */
58
+ export function coinpayProvider(clientId: string, clientSecret: string, base = "https://coinpayportal.com"): Provider {
59
+ return {
60
+ id: "coinpay",
61
+ name: "CoinPay",
62
+ clientId,
63
+ clientSecret,
64
+ authorizeUrl: `${base}/api/oauth/authorize`,
65
+ tokenUrl: `${base}/api/oauth/token`,
66
+ scope: "openid profile email",
67
+ pkce: true,
68
+ async identify(accessToken, send) {
69
+ const who = await readJson(await send(`${base}/api/oauth/userinfo`, { headers: { authorization: `Bearer ${accessToken}`, ...AGENT } }));
70
+ const subject = typeof who["sub"] === "string" ? who["sub"] : "";
71
+ if (!subject) return null;
72
+ const email = typeof who["email"] === "string" ? who["email"].toLowerCase() : "";
73
+ return { provider: "coinpay", subject, email, verified: who["email_verified"] === true };
74
+ },
75
+ };
76
+ }
77
+
78
+ /** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
79
+ export function pkcePair(): { verifier: string; challenge: string } {
80
+ const verifier = randomBytes(32).toString("base64url");
81
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
43
82
  }
44
83
 
45
84
  const AGENT = { "user-agent": "nixamp" };
@@ -114,6 +153,9 @@ export function providersFrom(env: Record<string, string | undefined>): Provider
114
153
  if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
115
154
  found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
116
155
  }
156
+ if (env["COINPAY_OAUTH_CLIENT_ID"] && env["COINPAY_OAUTH_CLIENT_SECRET"]) {
157
+ found.push(coinpayProvider(env["COINPAY_OAUTH_CLIENT_ID"], env["COINPAY_OAUTH_CLIENT_SECRET"], env["COINPAY_OAUTH_BASE"] || undefined));
158
+ }
117
159
  return found;
118
160
  }
119
161
 
@@ -122,13 +164,17 @@ export function redirectUri(site: string, provider: Provider): string {
122
164
  }
123
165
 
124
166
  /** Where to send the browser. `state` is the only thing standing between this and CSRF. */
125
- export function authorizeUrl(provider: Provider, site: string, state: string): string {
167
+ export function authorizeUrl(provider: Provider, site: string, state: string, challenge = ""): string {
126
168
  const url = new URL(provider.authorizeUrl);
127
169
  url.searchParams.set("client_id", provider.clientId);
128
170
  url.searchParams.set("redirect_uri", redirectUri(site, provider));
129
171
  url.searchParams.set("response_type", "code");
130
172
  url.searchParams.set("scope", provider.scope);
131
173
  url.searchParams.set("state", state);
174
+ if (challenge) {
175
+ url.searchParams.set("code_challenge", challenge);
176
+ url.searchParams.set("code_challenge_method", "S256");
177
+ }
132
178
  return url.toString();
133
179
  }
134
180
 
@@ -138,6 +184,7 @@ export async function exchangeCode(
138
184
  code: string,
139
185
  site: string,
140
186
  send: typeof fetch = fetch,
187
+ verifier = "",
141
188
  ): Promise<string> {
142
189
  const answer = await send(provider.tokenUrl, {
143
190
  method: "POST",
@@ -154,6 +201,7 @@ export async function exchangeCode(
154
201
  code,
155
202
  redirect_uri: redirectUri(site, provider),
156
203
  grant_type: "authorization_code",
204
+ ...(verifier ? { code_verifier: verifier } : {}),
157
205
  }).toString(),
158
206
  });
159
207
  const body = await readJson(answer);
@@ -208,7 +256,13 @@ export class Identities {
208
256
  }
209
257
 
210
258
  async resolve(identity: Identity): Promise<Account | null> {
211
- if (!identity.subject || !identity.email) return null;
259
+ if (!identity.subject) return null;
260
+ // An address the provider did not verify (CoinPay's) is a hint, not a
261
+ // claim: the account is found by subject alone, and a new one gets an
262
+ // address nobody can own, so purchases kept by email are never reached
263
+ // through it.
264
+ if (identity.verified === false) return this.resolveUnverified(identity);
265
+ if (!identity.email) return null;
212
266
  await this.ensure();
213
267
 
214
268
  // Already linked. The subject is what is matched, not the address: people
@@ -256,6 +310,37 @@ export class Identities {
256
310
  );
257
311
  return account;
258
312
  }
313
+
314
+ /** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
315
+ private async resolveUnverified(identity: Identity): Promise<Account | null> {
316
+ await this.ensure();
317
+ const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [identity.provider, identity.subject]);
318
+ const linked = rows[0];
319
+ const placeholder = `${identity.provider}-${identity.subject.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 60)}@${identity.provider}.invalid`;
320
+ if (linked) {
321
+ const id = String(linked["user_id"] ?? "");
322
+ return id ? { id, email: String(linked["email"] ?? "") || placeholder } : null;
323
+ }
324
+ const existing = await this.users.getUserByEmail(placeholder);
325
+ const account = existing?.id
326
+ ? { id: existing.id, email: existing.email ?? placeholder }
327
+ : await (async () => {
328
+ const made = await this.users.createUser({
329
+ email: placeholder,
330
+ password: null,
331
+ emailVerified: false,
332
+ profile: { signedUpWith: identity.provider, ...(identity.email ? { providerEmail: identity.email } : {}) },
333
+ });
334
+ return { id: String(made.id ?? ""), email: made.email ?? placeholder };
335
+ })();
336
+ if (!account.id) return null;
337
+ await this.db.query(
338
+ `INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
339
+ ON CONFLICT (provider, subject) DO NOTHING`,
340
+ [identity.provider, identity.subject, account.id, placeholder],
341
+ );
342
+ return account;
343
+ }
259
344
  }
260
345
 
261
346
  // --- the sign-in surface ----------------------------------------------------
@@ -273,6 +358,33 @@ export interface Pending {
273
358
  /** Set when this round trip is approving a terminal rather than a browser. */
274
359
  userCode: string;
275
360
  createdAt: number;
361
+ /** The PKCE verifier, for a provider that takes one. */
362
+ verifier?: string;
363
+ /** Where the browser goes once signed in (a path here, or an allowed site); "/" by default. */
364
+ redirect?: string;
365
+ }
366
+
367
+ /** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
368
+ export function signInReturnHosts(env: Record<string, string | undefined> = process.env): string[] {
369
+ const set = (env["NIXAMP_SIGNIN_RETURN_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean);
370
+ return set.length ? set : ["live.streaming", "live.streaming.pit.world", "bittorrented.com", "backtoschool.help"];
371
+ }
372
+
373
+ /**
374
+ * Where a sign-in may send the browser afterwards: a path on this site, or a
375
+ * page on one of the sites that send people here to sign in (live.streaming).
376
+ * Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
377
+ */
378
+ export function safeRedirect(value: unknown, allowedHosts: string[] = []): string {
379
+ if (typeof value !== "string" || value === "") return "/";
380
+ if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) return value.slice(0, 500);
381
+ try {
382
+ const url = new URL(value);
383
+ if (url.protocol === "https:" && allowedHosts.includes(url.hostname)) return url.href.slice(0, 500);
384
+ } catch {
385
+ // not a URL
386
+ }
387
+ return "/";
276
388
  }
277
389
 
278
390
  /** A state is only ever open for the length of one sign-in. */
@@ -298,11 +410,12 @@ export class SignIn {
298
410
  }
299
411
 
300
412
  /** Start a round trip, and answer the URL the browser should go to. */
301
- begin(provider: Provider, userCode = ""): string {
413
+ begin(provider: Provider, userCode = "", redirect = "/"): string {
302
414
  this.sweep();
303
415
  const state = randomBytes(24).toString("base64url");
304
- this.states.set(state, { provider: provider.id, userCode, createdAt: this.now() });
305
- return authorizeUrl(provider, this.site, state);
416
+ const pair = provider.pkce ? pkcePair() : null;
417
+ this.states.set(state, { provider: provider.id, userCode, createdAt: this.now(), redirect, ...(pair ? { verifier: pair.verifier } : {}) });
418
+ return authorizeUrl(provider, this.site, state, pair?.challenge ?? "");
306
419
  }
307
420
 
308
421
  /** Redeem a state exactly once, so a replayed callback finds nothing. */
@@ -476,8 +476,8 @@ export function renderMissing(what: string, base: string, nav: string): string {
476
476
  /** What a station page's head says: a station of ours or a peer's. */
477
477
  export interface StationFace { name: string; description?: string; video?: boolean }
478
478
 
479
- export function stationHead(station: StationFace, slug: string, base: string): string {
480
- const url = `${base}/radio/${slug}`;
479
+ export function stationHead(station: StationFace, slug: string, base: string, path = `/radio/${slug}`): string {
480
+ const url = `${base}${path}`;
481
481
  const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
482
482
  return `<title>${escape(station.name)} · live.streaming</title>
483
483
  <meta name="description" content="${escape(description)}" />
@@ -492,9 +492,9 @@ export function stationHead(station: StationFace, slug: string, base: string): s
492
492
  }
493
493
 
494
494
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
495
- export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string {
495
+ export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string {
496
496
  return html
497
- .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
497
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
498
498
  .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
499
499
  .replace(/<body>/, `<body>\n${nav}`);
500
500
  }