nixamp 0.56.0 → 0.57.1
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/channels.d.ts +20 -0
- package/dist/channels.js +24 -0
- package/dist/label-profiles.js +2 -2
- package/dist/library.d.ts +6 -0
- package/dist/library.js +11 -0
- package/dist/oauth.d.ts +36 -3
- package/dist/oauth.js +107 -7
- package/dist/playlist-input.d.ts +32 -7
- package/dist/playlist-input.js +20 -2
- package/dist/server.js +16 -6
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +23 -5
- package/dist/station-runner.d.ts +48 -0
- package/dist/station-runner.js +208 -4
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +11 -0
- package/package.json +1 -1
- package/src/channels.ts +30 -0
- package/src/label-profiles.ts +2 -2
- package/src/library.ts +14 -0
- package/src/oauth.ts +119 -6
- package/src/playlist-input.ts +46 -5
- package/src/server.ts +18 -5
- package/src/station-routes.ts +23 -6
- package/src/station-runner.ts +205 -4
- package/src/stations.ts +15 -0
- package/web/dist/assets/{ar-CwZJb_gN.js → ar-HteUbQ_m.js} +1 -1
- package/web/dist/assets/{cs-BQeUHU5U.js → cs-CjN_4DAJ.js} +1 -1
- package/web/dist/assets/{da-B9gLGX-j.js → da-CzsSXVfk.js} +1 -1
- package/web/dist/assets/{de-BtKJqMyc.js → de-D7zVt1UT.js} +1 -1
- package/web/dist/assets/{es-xCZPNIQ8.js → es-D8duNQiq.js} +1 -1
- package/web/dist/assets/{fi-Bs6hJxj9.js → fi-CSKCYE5E.js} +1 -1
- package/web/dist/assets/{fr-Db3viYEV.js → fr-DnjuIwaN.js} +1 -1
- package/web/dist/assets/{hi-BEO0uXMK.js → hi-ByZe82O5.js} +1 -1
- package/web/dist/assets/{hu-DddOFPOR.js → hu-B3iwlAuW.js} +1 -1
- package/web/dist/assets/{id-Ca4dDDtC.js → id-B58CS7rH.js} +1 -1
- package/web/dist/assets/{it-B9cFkgz7.js → it-CaVLbop_.js} +1 -1
- package/web/dist/assets/{main-CHubaPAu.js → main-yTRNIxIl.js} +2 -2
- package/web/dist/assets/{nl-kUxjpAwG.js → nl-Dk0ss8Tf.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-BE9Sxy3t.js → ru-CGuA41ji.js} +1 -1
- package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
- package/web/dist/assets/{sv-Cp6BIRXI.js → sv-B309Td6Z.js} +1 -1
- package/web/dist/assets/{uk-DavLH9cs.js → uk-CCCKvR-U.js} +1 -1
- package/web/dist/assets/{vi-eAlsSX6P.js → vi-DfVTbK_t.js} +1 -1
- package/web/dist/assets/{zh-D2Y8KIQG.js → zh-BU9_CD0f.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 +22 -22
package/dist/channels.d.ts
CHANGED
|
@@ -247,6 +247,16 @@ export declare class Channel {
|
|
|
247
247
|
* failure, so it does not count as a redial.
|
|
248
248
|
*/
|
|
249
249
|
skip(): boolean;
|
|
250
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
251
|
+
private heard;
|
|
252
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
253
|
+
resumePoint(): {
|
|
254
|
+
file: string;
|
|
255
|
+
upcoming: string[];
|
|
256
|
+
sinceBreak: number;
|
|
257
|
+
} | null;
|
|
258
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
259
|
+
get flowing(): boolean;
|
|
250
260
|
/** An entry's tags, as read when it was put on. */
|
|
251
261
|
tagsOf(file: string): {
|
|
252
262
|
title: string;
|
|
@@ -539,6 +549,16 @@ export declare class Channels {
|
|
|
539
549
|
artist: string;
|
|
540
550
|
album: string;
|
|
541
551
|
} | undefined;
|
|
552
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
553
|
+
resumePoint(id: string): {
|
|
554
|
+
file: string;
|
|
555
|
+
upcoming: string[];
|
|
556
|
+
sinceBreak: number;
|
|
557
|
+
} | null;
|
|
558
|
+
/** Whether a channel is up and has started sending. */
|
|
559
|
+
flowing(id: string): boolean;
|
|
560
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
561
|
+
listenersOf(id: string): number;
|
|
542
562
|
/** The next entry, now, on a channel playing a list of files. */
|
|
543
563
|
skip(id: string): boolean;
|
|
544
564
|
stop(id: string): boolean;
|
package/dist/channels.js
CHANGED
|
@@ -169,6 +169,16 @@ export class Channel {
|
|
|
169
169
|
this.info.redials = redials;
|
|
170
170
|
return done;
|
|
171
171
|
}
|
|
172
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
173
|
+
heard = 0;
|
|
174
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
175
|
+
resumePoint() {
|
|
176
|
+
return this.playlistInput?.resumePoint(this.heard) ?? null;
|
|
177
|
+
}
|
|
178
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
179
|
+
get flowing() {
|
|
180
|
+
return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
|
|
181
|
+
}
|
|
172
182
|
/** An entry's tags, as read when it was put on. */
|
|
173
183
|
tagsOf(file) {
|
|
174
184
|
return this.playlistInput?.tags.get(file);
|
|
@@ -334,6 +344,7 @@ export class Channel {
|
|
|
334
344
|
...resume.rotation,
|
|
335
345
|
}) : null;
|
|
336
346
|
this.playlistInput = playlistInput;
|
|
347
|
+
this.heard = 0;
|
|
337
348
|
const through = this.outroOn ? null : playlistInput
|
|
338
349
|
? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
|
|
339
350
|
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
@@ -413,6 +424,7 @@ export class Channel {
|
|
|
413
424
|
if (match) {
|
|
414
425
|
const seconds = Number(match[1]) / 1e6;
|
|
415
426
|
if (playlistInput) {
|
|
427
|
+
this.heard = seconds;
|
|
416
428
|
const place = playlistInput.place(seconds);
|
|
417
429
|
// A break (-1) belongs to no list; a track's index belongs to its own.
|
|
418
430
|
if (place.at >= 0 && this.info.playlist !== place.list)
|
|
@@ -1283,6 +1295,18 @@ export class Channels {
|
|
|
1283
1295
|
tagsOf(id, file) {
|
|
1284
1296
|
return this.open.get(id)?.tagsOf(file);
|
|
1285
1297
|
}
|
|
1298
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
1299
|
+
resumePoint(id) {
|
|
1300
|
+
return this.open.get(id)?.resumePoint() ?? null;
|
|
1301
|
+
}
|
|
1302
|
+
/** Whether a channel is up and has started sending. */
|
|
1303
|
+
flowing(id) {
|
|
1304
|
+
return this.open.get(id)?.flowing ?? false;
|
|
1305
|
+
}
|
|
1306
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
1307
|
+
listenersOf(id) {
|
|
1308
|
+
return this.open.get(id)?.listeners.size ?? 0;
|
|
1309
|
+
}
|
|
1286
1310
|
/** The next entry, now, on a channel playing a list of files. */
|
|
1287
1311
|
skip(id) {
|
|
1288
1312
|
return this.open.get(id)?.skip() ?? false;
|
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/library.d.ts
CHANGED
|
@@ -21,7 +21,13 @@ export interface Config {
|
|
|
21
21
|
limits?: Record<string, unknown>;
|
|
22
22
|
/** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
|
|
23
23
|
trustedProxies?: string[];
|
|
24
|
+
/** Seconds with nobody listening before a station sleeps; 0 never. 60 when unset. */
|
|
25
|
+
stationIdleSeconds?: number;
|
|
24
26
|
}
|
|
27
|
+
/** A station with nobody listening sleeps after this many seconds, unless config or NIXAMP_STATION_IDLE says otherwise. */
|
|
28
|
+
export declare const DEFAULT_STATION_IDLE_SECONDS = 60;
|
|
29
|
+
/** How long a station waits with nobody listening before it sleeps: env, then config, then 60; 0 is never. */
|
|
30
|
+
export declare function stationIdleSeconds(env: Record<string, string | undefined>, config: Config): number;
|
|
25
31
|
export declare function configPath(): string;
|
|
26
32
|
export declare function readConfig(): Config;
|
|
27
33
|
export declare function writeConfig(patch: Partial<Config>): void;
|
package/dist/library.js
CHANGED
|
@@ -19,6 +19,16 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import { join, resolve } from "node:path";
|
|
20
20
|
import { createInterface } from "node:readline/promises";
|
|
21
21
|
import { stateDir } from "./daemon.js";
|
|
22
|
+
/** A station with nobody listening sleeps after this many seconds, unless config or NIXAMP_STATION_IDLE says otherwise. */
|
|
23
|
+
export const DEFAULT_STATION_IDLE_SECONDS = 60;
|
|
24
|
+
/** How long a station waits with nobody listening before it sleeps: env, then config, then 60; 0 is never. */
|
|
25
|
+
export function stationIdleSeconds(env, config) {
|
|
26
|
+
const raw = env["NIXAMP_STATION_IDLE"];
|
|
27
|
+
if (raw !== undefined && raw.trim() !== "" && Number.isFinite(Number(raw)) && Number(raw) >= 0)
|
|
28
|
+
return Number(raw);
|
|
29
|
+
const set = config.stationIdleSeconds;
|
|
30
|
+
return typeof set === "number" && Number.isFinite(set) && set >= 0 ? set : DEFAULT_STATION_IDLE_SECONDS;
|
|
31
|
+
}
|
|
22
32
|
export function configPath() {
|
|
23
33
|
return join(stateDir(), "config.json");
|
|
24
34
|
}
|
|
@@ -33,6 +43,7 @@ export function readConfig() {
|
|
|
33
43
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
34
44
|
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] } : {}),
|
|
35
45
|
...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] } : {}),
|
|
46
|
+
...(typeof record["stationIdleSeconds"] === "number" ? { stationIdleSeconds: record["stationIdleSeconds"] } : {}),
|
|
36
47
|
...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: record["trustedProxies"].filter((one) => typeof one === "string") } : {}),
|
|
37
48
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
|
|
38
49
|
...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] } : {}),
|
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/playlist-input.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface PlaylistInput {
|
|
|
37
37
|
breaks?: {
|
|
38
38
|
everySeconds: number | (() => number);
|
|
39
39
|
next: () => Promise<string | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Seconds of airtime since the last break when this stream opens, asked
|
|
42
|
+
* once per open: a station that slept with nobody listening carries its
|
|
43
|
+
* clock across, so breaks come every so often of time on the air.
|
|
44
|
+
*/
|
|
45
|
+
sinceBreak?: () => number;
|
|
40
46
|
};
|
|
41
47
|
/** Told when an entry is passed over because it would not play. */
|
|
42
48
|
onFailure?: (event: {
|
|
@@ -51,6 +57,19 @@ export interface PlaylistInput {
|
|
|
51
57
|
* not a loop.
|
|
52
58
|
*/
|
|
53
59
|
export declare const MAX_FAILURES_IN_A_ROW = 5;
|
|
60
|
+
/** An entry that started: where it sits in the stream and in its pass. */
|
|
61
|
+
interface Entry {
|
|
62
|
+
at: number;
|
|
63
|
+
file: string;
|
|
64
|
+
start: number;
|
|
65
|
+
position: number;
|
|
66
|
+
list: readonly string[];
|
|
67
|
+
/** The pass's order and how far into it the next entry is, for resumePoint. */
|
|
68
|
+
order: readonly number[];
|
|
69
|
+
step: number;
|
|
70
|
+
/** Where the break clock stood when it started. */
|
|
71
|
+
lastBreak: number;
|
|
72
|
+
}
|
|
54
73
|
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
55
74
|
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
56
75
|
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
@@ -72,13 +91,7 @@ export declare class PlaylistInputStream {
|
|
|
72
91
|
}>;
|
|
73
92
|
constructor(options: PlaylistInput);
|
|
74
93
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
75
|
-
get history(): ReadonlyArray<
|
|
76
|
-
at: number;
|
|
77
|
-
file: string;
|
|
78
|
-
start: number;
|
|
79
|
-
position: number;
|
|
80
|
-
list: readonly string[];
|
|
81
|
-
}>;
|
|
94
|
+
get history(): ReadonlyArray<Entry>;
|
|
82
95
|
/**
|
|
83
96
|
* What is on at this point of the stream. `at` indexes `list`, the list as
|
|
84
97
|
* it was when that entry started: a station re-reads its list at every
|
|
@@ -90,6 +103,17 @@ export declare class PlaylistInputStream {
|
|
|
90
103
|
position: number;
|
|
91
104
|
list: readonly string[];
|
|
92
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Where to pick up after this point of the stream, for a station that
|
|
108
|
+
* sleeps with nobody listening: the file that is on ("" in a break), the
|
|
109
|
+
* rest of this pass in the order it was going to play, and the seconds
|
|
110
|
+
* of airtime since the last break.
|
|
111
|
+
*/
|
|
112
|
+
resumePoint(seconds: number): {
|
|
113
|
+
file: string;
|
|
114
|
+
upcoming: string[];
|
|
115
|
+
sinceBreak: number;
|
|
116
|
+
} | null;
|
|
93
117
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
94
118
|
private stopCurrent;
|
|
95
119
|
private skipping;
|
|
@@ -97,3 +121,4 @@ export declare class PlaylistInputStream {
|
|
|
97
121
|
skip(): boolean;
|
|
98
122
|
open(signal: AbortSignal): AsyncGenerator<Buffer>;
|
|
99
123
|
}
|
|
124
|
+
export {};
|
package/dist/playlist-input.js
CHANGED
|
@@ -40,6 +40,22 @@ export class PlaylistInputStream {
|
|
|
40
40
|
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position), list: entry.list }
|
|
41
41
|
: { at: this.options.at, position: this.options.position, list: this.options.files };
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Where to pick up after this point of the stream, for a station that
|
|
45
|
+
* sleeps with nobody listening: the file that is on ("" in a break), the
|
|
46
|
+
* rest of this pass in the order it was going to play, and the seconds
|
|
47
|
+
* of airtime since the last break.
|
|
48
|
+
*/
|
|
49
|
+
resumePoint(seconds) {
|
|
50
|
+
const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
|
|
51
|
+
if (!entry)
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
file: entry.at >= 0 ? entry.file : "",
|
|
55
|
+
upcoming: entry.order.slice(entry.step).map((index) => entry.list[index]).filter((one) => typeof one === "string"),
|
|
56
|
+
sinceBreak: entry.at < 0 ? 0 : Math.max(0, seconds - entry.lastBreak),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
43
59
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
44
60
|
stopCurrent = null;
|
|
45
61
|
skipping = false;
|
|
@@ -69,7 +85,9 @@ export class PlaylistInputStream {
|
|
|
69
85
|
let step = Math.min(Math.max(0, o.at), order.length);
|
|
70
86
|
let played = 0;
|
|
71
87
|
let last = "";
|
|
72
|
-
|
|
88
|
+
// Counted back from 0 by however long it had been, so a clock carried
|
|
89
|
+
// across a sleep comes due when it would have on the air.
|
|
90
|
+
let lastBreak = -Math.max(0, o.breaks?.sinceBreak?.() ?? 0);
|
|
73
91
|
// A film cut for a break, to be picked up where it stopped.
|
|
74
92
|
let resume = null;
|
|
75
93
|
for (;;) {
|
|
@@ -146,7 +164,7 @@ export class PlaylistInputStream {
|
|
|
146
164
|
const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
|
|
147
165
|
const cut = room >= 1 && full - room > 5;
|
|
148
166
|
const duration = cut ? room : full;
|
|
149
|
-
this.entries.push({ at, file, start: offset, position, list: files });
|
|
167
|
+
this.entries.push({ at, file, start: offset, position, list: files, order, step, lastBreak });
|
|
150
168
|
const hasVideo = Boolean(codecs.video);
|
|
151
169
|
const extra = o.video && !hasVideo
|
|
152
170
|
? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
|
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";
|
|
@@ -98,7 +98,7 @@ import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
|
|
|
98
98
|
import { NameError, Names } from "./names.js";
|
|
99
99
|
import { AcmeIssuer, Certs } from "./certs.js";
|
|
100
100
|
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
101
|
-
import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.js";
|
|
101
|
+
import { forbiddenLibrary, readConfig, readLibrary, stationIdleSeconds, writeConfig } from "./library.js";
|
|
102
102
|
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
103
103
|
import { Durable } from "./durable.js";
|
|
104
104
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
@@ -4509,7 +4509,9 @@ export function createHandler(engine, options) {
|
|
|
4509
4509
|
// A terminal can link straight here with the code it is showing, which
|
|
4510
4510
|
// is one hop shorter than the page for somebody who followed the link.
|
|
4511
4511
|
const grant = signIn.device.find(url.searchParams.get("device") ?? "");
|
|
4512
|
-
|
|
4512
|
+
// Back where the visit was going: a page here, or a site that sends people here to sign in.
|
|
4513
|
+
const redirect = safeRedirect(url.searchParams.get("redirect"), signInReturnHosts());
|
|
4514
|
+
response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "", redirect) });
|
|
4513
4515
|
response.end();
|
|
4514
4516
|
return;
|
|
4515
4517
|
}
|
|
@@ -4525,10 +4527,10 @@ export function createHandler(engine, options) {
|
|
|
4525
4527
|
html(response, 400, signInFailedPage(url.searchParams.get("error") ?? "The provider sent no code."));
|
|
4526
4528
|
return;
|
|
4527
4529
|
}
|
|
4528
|
-
const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
|
|
4530
|
+
const access = await exchangeCode(provider, code, signIn.site, fetch, pending.verifier ?? "").catch(() => "");
|
|
4529
4531
|
const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
|
|
4530
4532
|
if (identity === null) {
|
|
4531
|
-
html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
|
|
4533
|
+
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
4534
|
return;
|
|
4533
4535
|
}
|
|
4534
4536
|
const result = await accounts.signInWith(identity);
|
|
@@ -4548,7 +4550,7 @@ export function createHandler(engine, options) {
|
|
|
4548
4550
|
}
|
|
4549
4551
|
response.writeHead(302, {
|
|
4550
4552
|
"set-cookie": sessionCookie(result.token, secure),
|
|
4551
|
-
location: "/",
|
|
4553
|
+
location: pending.redirect || "/",
|
|
4552
4554
|
});
|
|
4553
4555
|
response.end();
|
|
4554
4556
|
return;
|
|
@@ -5501,6 +5503,11 @@ export function createHandler(engine, options) {
|
|
|
5501
5503
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
5502
5504
|
return;
|
|
5503
5505
|
}
|
|
5506
|
+
// A station asleep with nobody listening is woken by whoever asks for
|
|
5507
|
+
// its channel here too, and they are held for its first bytes.
|
|
5508
|
+
if ((action === "hls" || action === undefined) && request.method === "GET" && !channels.has(id) && stationOfChannel(id) && options.stations?.runner.isSleeping(stationOfChannel(id))) {
|
|
5509
|
+
await options.stations.runner.awake(stationOfChannel(id));
|
|
5510
|
+
}
|
|
5504
5511
|
// The same channel as HLS: a playlist of short files, which is what
|
|
5505
5512
|
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
5506
5513
|
// below, and spun on it a few times before giving up. Packaged on
|
|
@@ -6956,6 +6963,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6956
6963
|
onRestart: (id) => stationUsage.restarted(id),
|
|
6957
6964
|
onFailure: (id) => stationUsage.failed(id),
|
|
6958
6965
|
artistOf: (file) => trackFor(engine, file)?.artist ?? "",
|
|
6966
|
+
// Nobody listening for this long and a station sleeps until somebody does.
|
|
6967
|
+
idleSeconds: () => stationIdleSeconds(process.env, readConfig()),
|
|
6959
6968
|
store: stationStore,
|
|
6960
6969
|
channels,
|
|
6961
6970
|
pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
|
|
@@ -7245,6 +7254,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7245
7254
|
});
|
|
7246
7255
|
}
|
|
7247
7256
|
stationRunner.restore();
|
|
7257
|
+
stationRunner.watchIdle();
|
|
7248
7258
|
// Where each film has got to, written down every so often, so a restart
|
|
7249
7259
|
// picks it up from about there rather than from the start. Only when it
|
|
7250
7260
|
// has changed: a server carrying nothing but live television writes
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -112,6 +112,7 @@ export declare function guideEntries(ctx: StationContext): Array<PublicStation &
|
|
|
112
112
|
export declare function ownerView(ctx: StationContext, station: Station): OwnerStation & {
|
|
113
113
|
links: ReturnType<typeof links>;
|
|
114
114
|
nowPlaying: string;
|
|
115
|
+
sleeping: boolean;
|
|
115
116
|
};
|
|
116
117
|
/** Answers anything under /api/v1/stations; false when the path is not one. */
|
|
117
118
|
export declare function handleStations(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: StationContext): Promise<boolean>;
|