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/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
|
-
|
|
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
package/src/guide-rooms.ts
CHANGED
|
@@ -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
|
|
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";
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/label-profiles.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
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. */
|
package/src/radio-pages.ts
CHANGED
|
@@ -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}
|
|
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
|
}
|