nixamp 0.28.38 → 0.28.41
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/README.md +6 -1
- package/backtoschool/dist/assets/{ar-BJrZUvbj.js → ar-BQPKp0M2.js} +1 -1
- package/backtoschool/dist/assets/{cs-DlOUfUxw.js → cs-DjJnhGNB.js} +1 -1
- package/{web/dist/assets/da-qufG3QK3.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
- package/{web/dist/assets/de-BddWhvFB.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
- package/backtoschool/dist/assets/{es-DezARIfJ.js → es-BBlBPcq1.js} +1 -1
- package/backtoschool/dist/assets/{fi-B_zd8oyM.js → fi-ffo35_4W.js} +1 -1
- package/backtoschool/dist/assets/{fr-CMtbCk6s.js → fr-BVvavrk7.js} +1 -1
- package/{web/dist/assets/hi-BMTJ6Zea.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
- package/backtoschool/dist/assets/{hls-3VKVEQE3-BFn8NctW.js → hls-3VKVEQE3-QZWXCWjR.js} +1 -1
- package/backtoschool/dist/assets/{hu-DDqnEZUk.js → hu-BoJj7pZa.js} +1 -1
- package/backtoschool/dist/assets/{id-GE_XdNH1.js → id-CXN9gchF.js} +1 -1
- package/backtoschool/dist/assets/{index-Bg1kYd8S.css → index-BaiHu9CD.css} +1 -1
- package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
- package/backtoschool/dist/assets/{it-BbCfCf0u.js → it-C9g0xD6S.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-D6OaCwgB.js → mpegts-GAwbg-pX.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-LO6RVLD6-CmKDQL_J.js → mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
- package/{web/dist/assets/nl-gqa06B5b.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
- package/{web/dist/assets/ru-Bm2pudvY.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
- package/backtoschool/dist/assets/{sv-CCLFqo_m.js → sv-DH0ZO3Eh.js} +1 -1
- package/{web/dist/assets/uk-CncP4EX7.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
- package/backtoschool/dist/assets/{vi-D6DJBpIt.js → vi-D0QPpzbE.js} +1 -1
- package/{web/dist/assets/zh-CtcojTCQ.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
- package/backtoschool/dist/index.html +59 -8
- package/dist/live-api.d.ts +10 -0
- package/dist/live-api.js +8 -0
- package/dist/live-voice.d.ts +3 -0
- package/dist/live-voice.js +27 -4
- package/dist/nixamp-link-api.d.ts +44 -0
- package/dist/nixamp-link-api.js +190 -0
- package/dist/nixamp-link-types.d.ts +33 -0
- package/dist/nixamp-link-types.js +5 -0
- package/dist/nixamp-link.d.ts +66 -0
- package/dist/nixamp-link.js +231 -0
- package/dist/notify.d.ts +6 -4
- package/dist/notify.js +14 -25
- package/dist/oauth-api.d.ts +1 -1
- package/dist/oauth-api.js +27 -9
- package/dist/oauth-server.d.ts +9 -1
- package/dist/oauth-server.js +24 -2
- package/dist/profiles.d.ts +85 -0
- package/dist/profiles.js +0 -0
- package/dist/server.d.ts +12 -2
- package/dist/server.js +205 -13
- package/dist/voice-provider.d.ts +9 -0
- package/dist/voice-provider.js +27 -0
- package/dist/watch-party.d.ts +8 -0
- package/dist/watch-party.js +21 -0
- package/package.json +6 -6
- package/src/live-api.ts +13 -0
- package/src/live-voice.ts +25 -3
- package/src/nixamp-link-api.ts +220 -0
- package/src/nixamp-link-types.ts +36 -0
- package/src/nixamp-link.ts +265 -0
- package/src/notify.ts +19 -34
- package/src/oauth-api.ts +30 -11
- package/src/oauth-server.ts +24 -2
- package/src/profiles.ts +0 -0
- package/src/server.ts +192 -15
- package/src/voice-provider.ts +22 -0
- package/src/watch-party.ts +23 -0
- package/web/dist/assets/{ar-tv7nkpiY.js → ar-BqLaLSMR.js} +1 -1
- package/web/dist/assets/{cs-D9o3fJ2P.js → cs-CfoQJwKM.js} +1 -1
- package/{backtoschool/dist/assets/da-C724NwZt.js → web/dist/assets/da-8MmH23fh.js} +1 -1
- package/{backtoschool/dist/assets/de-DGjw5Y71.js → web/dist/assets/de-Brry3OMh.js} +1 -1
- package/web/dist/assets/{es-DURiUJ4W.js → es-CGxJs7b8.js} +1 -1
- package/web/dist/assets/{fi-D4OG-FzU.js → fi-BDAAZtto.js} +1 -1
- package/web/dist/assets/{fr-CYahUXc4.js → fr-9sxYL0Qy.js} +1 -1
- package/{backtoschool/dist/assets/hi-B33KN-Pt.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BblrzMwh.js → hls-3VKVEQE3-Dx7qltzA.js} +1 -1
- package/web/dist/assets/{hu-Cu0wj9R4.js → hu-CvB9aIyN.js} +1 -1
- package/web/dist/assets/{id-DbNfLRpW.js → id-Bmrh06Hw.js} +1 -1
- package/web/dist/assets/index-BQuoiKg9.js +3 -0
- package/web/dist/assets/{index-JvFXyU1l.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/{it-DrOc430y.js → it-SbaxFUIP.js} +1 -1
- package/web/dist/assets/{mpegts-D2RCQTRm.js → mpegts-BdwONu8t.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-SH6yVu0A.js → mpegts-LO6RVLD6-CqW3DT1n.js} +1 -1
- package/{backtoschool/dist/assets/nl-CDHaP4Aw.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
- package/{backtoschool/dist/assets/ru-DiZnvRs-.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
- package/web/dist/assets/{sv-DjYUXKKI.js → sv-BAyIUHP5.js} +1 -1
- package/{backtoschool/dist/assets/uk-BtLBQT-n.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
- package/web/dist/assets/{vi-B4xhD0GU.js → vi-BdH3SLlR.js} +1 -1
- package/{backtoschool/dist/assets/zh-C7GUPWP3.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
- package/web/dist/index.html +33 -2
- package/web/dist/sw.js +23 -23
- package/backtoschool/dist/assets/index-CPUjs97F.js +0 -112
- package/web/dist/assets/index-CzW_V6oA.js +0 -3
package/dist/watch-party.js
CHANGED
|
@@ -200,6 +200,27 @@ export class WatchParties {
|
|
|
200
200
|
return null;
|
|
201
201
|
return { party: partyFrom(row, event), event };
|
|
202
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* A party by its code alone, for somebody who has only the code.
|
|
205
|
+
*
|
|
206
|
+
* A code is unique per origin, not across them; a person typing one into
|
|
207
|
+
* nixamp.com is not asked which site it came from. The one still live, or
|
|
208
|
+
* the most recently touched, is the one they mean.
|
|
209
|
+
*/
|
|
210
|
+
async byAnyCode(partyCode) {
|
|
211
|
+
await this.ensure();
|
|
212
|
+
const code = cleanPartyCode(partyCode);
|
|
213
|
+
const { rows } = await this.options.db.query(`SELECT p.* FROM ${TABLE} p JOIN live_events e ON e.id = p.event_id
|
|
214
|
+
WHERE p.party_code = $1
|
|
215
|
+
ORDER BY (e.status = 'live') DESC, p.updated_at DESC LIMIT 1`, [code]);
|
|
216
|
+
const row = rows[0];
|
|
217
|
+
if (!row)
|
|
218
|
+
return null;
|
|
219
|
+
const event = await this.options.events.get(String(row["event_id"] ?? ""));
|
|
220
|
+
if (!event)
|
|
221
|
+
return null;
|
|
222
|
+
return { party: partyFrom(row, event), event };
|
|
223
|
+
}
|
|
203
224
|
/** The party behind a nixamp room or slug, for a client that has only that. */
|
|
204
225
|
async byEvent(reference) {
|
|
205
226
|
await this.ensure();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.41",
|
|
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",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@profullstack/auth-system": "^0.6.0",
|
|
49
49
|
"@profullstack/hqtui": "^0.5.0",
|
|
50
|
+
"@profullstack/notifications": "^0.1.0",
|
|
50
51
|
"@profullstack/synconfig": "^0.1.2",
|
|
51
52
|
"@profullstack/throttle": "0.2.2",
|
|
52
|
-
"@profullstack/x402-gateway": "0.
|
|
53
|
+
"@profullstack/x402-gateway": "^0.7.0",
|
|
53
54
|
"acme-client": "5.4.0",
|
|
54
|
-
"pg": "^8.23.0"
|
|
55
|
-
"web-push": "^3.6.7"
|
|
55
|
+
"pg": "^8.23.0"
|
|
56
56
|
},
|
|
57
57
|
"optionalDependencies": {
|
|
58
58
|
"@huggingface/transformers": "^4.2.0"
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^26",
|
|
62
62
|
"@types/pg": "^8.23.1",
|
|
63
|
-
"@types/web-push": "^3.6.4",
|
|
64
63
|
"typescript": "^7.0.2"
|
|
65
64
|
},
|
|
66
65
|
"trustedDependencies": [
|
|
@@ -82,6 +81,7 @@
|
|
|
82
81
|
},
|
|
83
82
|
"overrides": {
|
|
84
83
|
"https-proxy-agent": "7.0.6",
|
|
85
|
-
"tslib": "2.8.1"
|
|
84
|
+
"tslib": "2.8.1",
|
|
85
|
+
"@profullstack/x402-gateway": "^0.7.0"
|
|
86
86
|
}
|
|
87
87
|
}
|
package/src/live-api.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface LiveApiOptions {
|
|
|
36
36
|
site?: string;
|
|
37
37
|
email?: (to: string, note: { title: string; body: string; url: string }) => Promise<boolean>;
|
|
38
38
|
onEventUpdated?: (event: LiveEvent, previous: LiveEvent) => void;
|
|
39
|
+
/**
|
|
40
|
+
* The account's own card -- name, homepage, photo -- so a class made
|
|
41
|
+
* without them carries the host's instead of nothing. What the class
|
|
42
|
+
* says explicitly still wins.
|
|
43
|
+
*/
|
|
44
|
+
hostProfile?: (userId: string) => Promise<{ hostName: string; homepageUrl: string; avatarUrl: string }>;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
const CORS = {
|
|
@@ -309,6 +315,13 @@ export async function handleLiveApi(
|
|
|
309
315
|
const account = await requiredAccount(request, response, options);
|
|
310
316
|
if (!account) return true;
|
|
311
317
|
const input = await body(request);
|
|
318
|
+
if (options.hostProfile) {
|
|
319
|
+
const card = await options.hostProfile(account.id);
|
|
320
|
+
for (const key of ["hostName", "homepageUrl", "avatarUrl"] as const) {
|
|
321
|
+
const given = input[key];
|
|
322
|
+
if (card[key] && (given === undefined || given === null || given === "")) input[key] = card[key];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
312
325
|
const event = await options.events.create(account.id, { ...input, title: input["title"] });
|
|
313
326
|
json(response, 201, view(event, account));
|
|
314
327
|
return true;
|
package/src/live-voice.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { VoiceProfile } from "./voice-profile.ts";
|
|
|
6
6
|
import { Guard } from "./guard.ts";
|
|
7
7
|
import type { TranslationMeter } from "./translation-passes.ts";
|
|
8
8
|
import type { Queryable } from "./follows.ts";
|
|
9
|
+
import { voiceProviderFailure } from "./voice-provider.ts";
|
|
9
10
|
|
|
10
11
|
export const LIVE_VOICE_MODEL = "eleven_flash_v2_5";
|
|
11
12
|
export const LIVE_VOICE_RATE = 16_000;
|
|
@@ -37,6 +38,20 @@ export class LiveVoice {
|
|
|
37
38
|
private readonly dailyAudioSeconds: number;
|
|
38
39
|
private readonly userDailyChars: number;
|
|
39
40
|
private readonly userDailyAudioSeconds: number;
|
|
41
|
+
private providerPause: { until: number; error: SpeechError } | null = null;
|
|
42
|
+
|
|
43
|
+
private checkProvider(): void {
|
|
44
|
+
if (this.providerPause && this.providerPause.until > this.now()) throw this.providerPause.error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async providerFailed(response: Response, operation: string): Promise<never> {
|
|
48
|
+
const failure = await voiceProviderFailure(response);
|
|
49
|
+
const until = this.now() + failure.cooldownMs;
|
|
50
|
+
// Another in-flight response must not shorten an existing billing pause.
|
|
51
|
+
if (!this.providerPause || this.providerPause.until < until) this.providerPause = { until, error: failure.error };
|
|
52
|
+
console.error(JSON.stringify({ event: "voice_provider_failure", provider: "elevenlabs", operation, status: response.status, code: failure.code, cooldownMs: failure.cooldownMs }));
|
|
53
|
+
throw failure.error;
|
|
54
|
+
}
|
|
40
55
|
|
|
41
56
|
constructor(options: { apiKey?: string; fetcher?: typeof fetch; now?: () => number; charsPerMinute?: number; dailyChars?: number; dailyAudioSeconds?: number; userDailyChars?: number; userDailyAudioSeconds?: number; db?: Queryable; billing?: TranslationMeter } = {}) {
|
|
42
57
|
this.key = options.apiKey ?? process.env["ELEVENLABS_API_KEY"] ?? "";
|
|
@@ -60,6 +75,7 @@ export class LiveVoice {
|
|
|
60
75
|
async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
|
|
61
76
|
if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
|
|
62
77
|
await meter?.require(by, resource);
|
|
78
|
+
this.checkProvider();
|
|
63
79
|
const wav = decodeWav(bytes);
|
|
64
80
|
const seconds = wav.samples.length / wav.rate;
|
|
65
81
|
if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
|
|
@@ -77,6 +93,7 @@ export class LiveVoice {
|
|
|
77
93
|
await this.reserve(`scribe:user:${by}`, billed, this.userDailyAudioSeconds, 86_400_000);
|
|
78
94
|
await this.reserve("scribe:server", billed, this.dailyAudioSeconds, 86_400_000);
|
|
79
95
|
signal?.throwIfAborted();
|
|
96
|
+
this.checkProvider();
|
|
80
97
|
const form = new FormData();
|
|
81
98
|
// Canonical PCM prevents a crafted container from billing more audio
|
|
82
99
|
// than the duration we validated, and removes uploaded metadata.
|
|
@@ -91,8 +108,9 @@ export class LiveVoice {
|
|
|
91
108
|
method: "POST", headers: { "xi-api-key": this.key }, body: form,
|
|
92
109
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
93
110
|
});
|
|
94
|
-
if (!answer.ok)
|
|
111
|
+
if (!answer.ok) await this.providerFailed(answer, "transcription");
|
|
95
112
|
accepted = true;
|
|
113
|
+
console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "transcription", account: createHash("sha256").update(by).digest("hex").slice(0, 16), audioSeconds: seconds }));
|
|
96
114
|
if (reservation) await meter!.commit(reservation);
|
|
97
115
|
return speakerTurns(await answer.json() as ScribeResult, wav);
|
|
98
116
|
} catch (error) {
|
|
@@ -103,12 +121,13 @@ export class LiveVoice {
|
|
|
103
121
|
|
|
104
122
|
async voices(): Promise<LiveVoiceChoice[]> {
|
|
105
123
|
if (!this.available()) throw new SpeechError("translated audio needs ELEVENLABS_API_KEY on the account server", 503);
|
|
124
|
+
this.checkProvider();
|
|
106
125
|
if (!this.catalog || this.now() >= this.catalogUntil) {
|
|
107
126
|
this.catalogUntil = this.now() + 3600_000;
|
|
108
127
|
this.catalog = this.fetcher("https://api.elevenlabs.io/v2/voices?page_size=100&voice_type=default", {
|
|
109
128
|
headers: { "xi-api-key": this.key }, signal: AbortSignal.timeout(8000),
|
|
110
129
|
}).then(async response => {
|
|
111
|
-
if (!response.ok)
|
|
130
|
+
if (!response.ok) await this.providerFailed(response, "voices");
|
|
112
131
|
const body = await response.json() as { voices: { voice_id: string; name: string; labels?: Record<string, string> }[] };
|
|
113
132
|
return body.voices.filter(voice => /^[a-zA-Z0-9_-]+$/.test(voice.voice_id)).map(voice => ({
|
|
114
133
|
id: voice.voice_id, name: voice.name, gender: voice.labels?.["gender"] ?? "neutral", language: voice.labels?.["language"] ?? "",
|
|
@@ -238,6 +257,7 @@ export class LiveVoice {
|
|
|
238
257
|
let accepted = false;
|
|
239
258
|
try {
|
|
240
259
|
await this.charge(by, ask.channel ?? "direct", text.length);
|
|
260
|
+
this.checkProvider();
|
|
241
261
|
signal?.throwIfAborted();
|
|
242
262
|
reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
243
263
|
const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
|
|
@@ -246,9 +266,11 @@ export class LiveVoice {
|
|
|
246
266
|
body: JSON.stringify({ text, model_id: LIVE_VOICE_MODEL, language_code: ask.language }),
|
|
247
267
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
248
268
|
});
|
|
249
|
-
if (!response.ok
|
|
269
|
+
if (!response.ok) await this.providerFailed(response, "synthesis");
|
|
270
|
+
if (!response.body) throw new SpeechError("ElevenLabs returned no audio", 502);
|
|
250
271
|
// Once the provider accepts, aborting playback cannot refund heard audio.
|
|
251
272
|
accepted = true;
|
|
273
|
+
console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "synthesis", account: createHash("sha256").update(by).digest("hex").slice(0, 16), characters: text.length }));
|
|
252
274
|
if (reservation) await meter!.commit(reservation);
|
|
253
275
|
const [play, keep] = response.body.tee();
|
|
254
276
|
void (async () => {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routes behind "Connect nixamp" on a site that runs on this codebase
|
|
3
|
+
* but is not nixamp.com.
|
|
4
|
+
*
|
|
5
|
+
* GET /api/v1/nixamp/connect send the browser to nixamp.com's consent page
|
|
6
|
+
* GET /api/v1/nixamp/callback the code comes back; tokens are kept
|
|
7
|
+
* GET /api/v1/nixamp/connection whether this account is connected, and as whom
|
|
8
|
+
* DELETE /api/v1/nixamp/connection withdraw the grant, both sides
|
|
9
|
+
* GET /api/v1/nixamp/streams the servers you run on nixamp, and what is live
|
|
10
|
+
*
|
|
11
|
+
* All five want the site's own session: the person, signed in here. What
|
|
12
|
+
* they hold on nixamp's side is a client token, and it never leaves the
|
|
13
|
+
* server -- the page only ever learns a handle and a list of streams.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
import { tokenFrom, type Accounts } from "./accounts.ts";
|
|
17
|
+
import type { NixampLinks } from "./nixamp-link.ts";
|
|
18
|
+
import type { ServerStreams, StreamPick } from "./nixamp-link-types.ts";
|
|
19
|
+
export type { ServerStreams, StreamPick } from "./nixamp-link-types.ts";
|
|
20
|
+
|
|
21
|
+
export interface NixampLinkApiOptions {
|
|
22
|
+
links: NixampLinks;
|
|
23
|
+
accounts: Accounts;
|
|
24
|
+
/** nixamp.com, or whatever is the issuer. */
|
|
25
|
+
issuer: string;
|
|
26
|
+
/** This site, as the redirect URI is built from it: https://backtoschool.help. */
|
|
27
|
+
site: string;
|
|
28
|
+
/** The redirect URIs the client registered on the issuer; the callback must be one. */
|
|
29
|
+
redirectUris: string[];
|
|
30
|
+
/** Where the browser lands after the callback. */
|
|
31
|
+
home?: string;
|
|
32
|
+
secureCookies?: boolean;
|
|
33
|
+
fetcher?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COOKIE = "nixamp_connect";
|
|
37
|
+
const CALLBACK = "/api/v1/nixamp/callback";
|
|
38
|
+
|
|
39
|
+
export function nixampLinkPath(path: string): boolean {
|
|
40
|
+
return path === "/api/v1/nixamp/connect" || path === CALLBACK || path === "/api/v1/nixamp/connection" || path === "/api/v1/nixamp/streams";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function json(response: ServerResponse, code: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
44
|
+
const value = JSON.stringify(body);
|
|
45
|
+
response.writeHead(code, {
|
|
46
|
+
...headers,
|
|
47
|
+
"content-type": "application/json; charset=utf-8",
|
|
48
|
+
"content-length": Buffer.byteLength(value),
|
|
49
|
+
"cache-control": "no-store",
|
|
50
|
+
});
|
|
51
|
+
response.end(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cookieValue(headers: IncomingMessage["headers"], name: string): string {
|
|
55
|
+
const raw = headers.cookie ?? "";
|
|
56
|
+
for (const part of raw.split(";")) {
|
|
57
|
+
const [key, ...rest] = part.trim().split("=");
|
|
58
|
+
if (key === name) return decodeURIComponent(rest.join("="));
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function legCookie(value: string, secure: boolean): string {
|
|
64
|
+
const parts = [`${COOKIE}=${encodeURIComponent(value)}`, `Path=${CALLBACK}`, "Max-Age=600", "SameSite=Lax", "HttpOnly"];
|
|
65
|
+
if (secure) parts.push("Secure");
|
|
66
|
+
return parts.join("; ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearedLegCookie(): string {
|
|
70
|
+
return `${COOKIE}=; Path=${CALLBACK}; Max-Age=0; SameSite=Lax; HttpOnly`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function landing(home: string, outcome: string, reason = ""): string {
|
|
74
|
+
const url = new URL(home, "http://placeholder.invalid");
|
|
75
|
+
url.searchParams.set("nixamp", outcome);
|
|
76
|
+
if (reason) url.searchParams.set("reason", reason.slice(0, 200));
|
|
77
|
+
return `${url.pathname}${url.search}${url.hash || "#settings"}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every server the nixamp account remembers, asked what it is doing.
|
|
82
|
+
*
|
|
83
|
+
* The account's own list comes from nixamp.com with the client token; each
|
|
84
|
+
* server is then asked directly, with the share key the account kept for it,
|
|
85
|
+
* and given four seconds. One that does not answer is listed as out of
|
|
86
|
+
* reach rather than dropped, so a host sees why a stream is missing.
|
|
87
|
+
*/
|
|
88
|
+
export async function streamsFor(token: string, issuer: string, pageSite: string, fetcher: typeof fetch): Promise<ServerStreams[]> {
|
|
89
|
+
const base = issuer.replace(/\/+$/, "");
|
|
90
|
+
let servers: { id: string; name: string; url: string; key: string }[] = [];
|
|
91
|
+
const answer = await fetcher(`${base}/api/v1/servers`, {
|
|
92
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
93
|
+
signal: AbortSignal.timeout(8000),
|
|
94
|
+
});
|
|
95
|
+
if (!answer.ok) throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
|
|
96
|
+
const body = (await answer.json().catch(() => ({}))) as { servers?: typeof servers };
|
|
97
|
+
servers = Array.isArray(body.servers) ? body.servers : [];
|
|
98
|
+
const page = pageSite.replace(/\/+$/, "");
|
|
99
|
+
const linkTo = (address: string, play: string): string => `${page}/?url=${encodeURIComponent(address)}&play=${encodeURIComponent(play)}`;
|
|
100
|
+
return Promise.all(servers.map(async (server): Promise<ServerStreams> => {
|
|
101
|
+
const out: ServerStreams = { id: server.id, name: server.name || server.url, url: server.url, reachable: false, playing: false, nowPlaying: "", live: "", channels: [] };
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL("/api/streams", server.url.endsWith("/") ? server.url : `${server.url}/`);
|
|
104
|
+
if (server.key) url.searchParams.set("k", server.key);
|
|
105
|
+
const response = await fetcher(url.href, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(4000) });
|
|
106
|
+
if (!response.ok) return out;
|
|
107
|
+
const data = (await response.json()) as {
|
|
108
|
+
server?: { name?: string; nowPlaying?: string; playing?: boolean; url?: string };
|
|
109
|
+
channels?: { id: string; name: string; kind?: string; listeners?: number }[];
|
|
110
|
+
};
|
|
111
|
+
// What a viewer link is built on: the server's own view-only address
|
|
112
|
+
// when it publishes one, else its plain address. Never the key the
|
|
113
|
+
// account kept, which may open the controls.
|
|
114
|
+
const viewer = data.server?.url && /^https?:\/\//.test(data.server.url) ? data.server.url : server.url;
|
|
115
|
+
out.reachable = true;
|
|
116
|
+
out.name = data.server?.name || out.name;
|
|
117
|
+
out.playing = data.server?.playing === true;
|
|
118
|
+
out.nowPlaying = data.server?.nowPlaying ?? "";
|
|
119
|
+
out.live = out.playing ? linkTo(viewer, "live") : "";
|
|
120
|
+
out.channels = (data.channels ?? []).map((one): StreamPick => ({
|
|
121
|
+
server: out.name,
|
|
122
|
+
serverUrl: server.url,
|
|
123
|
+
id: one.id,
|
|
124
|
+
name: one.name,
|
|
125
|
+
kind: one.kind ?? "audio",
|
|
126
|
+
listeners: typeof one.listeners === "number" ? one.listeners : 0,
|
|
127
|
+
link: linkTo(viewer, `channel:${one.id}`),
|
|
128
|
+
}));
|
|
129
|
+
} catch {
|
|
130
|
+
// Out of reach: said so, above.
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function handleNixampLinkApi(
|
|
137
|
+
request: IncomingMessage,
|
|
138
|
+
response: ServerResponse,
|
|
139
|
+
url: URL,
|
|
140
|
+
options: NixampLinkApiOptions,
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
const path = url.pathname;
|
|
143
|
+
if (!nixampLinkPath(path)) return false;
|
|
144
|
+
const home = options.home ?? "/";
|
|
145
|
+
const redirectUri = `${options.site.replace(/\/+$/, "")}${CALLBACK}`;
|
|
146
|
+
const registered = options.redirectUris.includes(redirectUri);
|
|
147
|
+
|
|
148
|
+
const account = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
149
|
+
if (account === null) {
|
|
150
|
+
if (path === CALLBACK) {
|
|
151
|
+
response.writeHead(302, { location: landing(home, "failed", "sign in here first, then connect again"), "set-cookie": clearedLegCookie() });
|
|
152
|
+
response.end();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
json(response, 401, { error: "sign in first" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (path === "/api/v1/nixamp/connect") {
|
|
160
|
+
if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
|
|
161
|
+
if (!registered) { json(response, 400, { error: "this site is not registered as a nixamp client" }); return true; }
|
|
162
|
+
const leg = options.links.begin(redirectUri);
|
|
163
|
+
response.writeHead(302, { location: leg.url, "set-cookie": legCookie(`${leg.state}.${leg.verifier}`, options.secureCookies ?? false), "cache-control": "no-store" });
|
|
164
|
+
response.end();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (path === CALLBACK) {
|
|
169
|
+
if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
|
|
170
|
+
const kept = cookieValue(request.headers, COOKIE);
|
|
171
|
+
const dot = kept.indexOf(".");
|
|
172
|
+
const state = dot > 0 ? kept.slice(0, dot) : "";
|
|
173
|
+
const verifier = dot > 0 ? kept.slice(dot + 1) : "";
|
|
174
|
+
const headers = { "set-cookie": clearedLegCookie(), "cache-control": "no-store" };
|
|
175
|
+
const refused = url.searchParams.get("error");
|
|
176
|
+
if (refused) {
|
|
177
|
+
response.writeHead(302, { ...headers, location: landing(home, refused === "access_denied" ? "denied" : "failed", url.searchParams.get("error_description") ?? refused) });
|
|
178
|
+
response.end();
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const code = url.searchParams.get("code") ?? "";
|
|
182
|
+
if (state === "" || url.searchParams.get("state") !== state || code === "") {
|
|
183
|
+
response.writeHead(302, { ...headers, location: landing(home, "failed", "that link is not the one this browser started; try again") });
|
|
184
|
+
response.end();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await options.links.finish(account.id, { code, verifier, redirectUri });
|
|
189
|
+
response.writeHead(302, { ...headers, location: landing(home, "connected") });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
response.writeHead(302, { ...headers, location: landing(home, "failed", (error as Error).message) });
|
|
192
|
+
}
|
|
193
|
+
response.end();
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (path === "/api/v1/nixamp/connection") {
|
|
198
|
+
if (request.method === "GET") {
|
|
199
|
+
json(response, 200, { ...(await options.links.of(account.id)), available: registered });
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (request.method === "DELETE") {
|
|
203
|
+
json(response, 200, { ok: true, withdrawn: await options.links.disconnect(account.id) });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
json(response, 405, { error: "GET or DELETE" });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// /api/v1/nixamp/streams
|
|
211
|
+
if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
|
|
212
|
+
const token = await options.links.accessToken(account.id);
|
|
213
|
+
if (token === "") { json(response, 409, { error: "connect your nixamp account first", connected: false }); return true; }
|
|
214
|
+
try {
|
|
215
|
+
json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
json(response, 502, { error: (error as Error).message });
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "Connect nixamp" looks like from a page: a leaf module, so a client
|
|
3
|
+
* bundle can name these shapes without pulling the server in behind them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The connection as the account holder sees it. Never the tokens. */
|
|
7
|
+
export interface LinkView {
|
|
8
|
+
connected: boolean;
|
|
9
|
+
handle: string;
|
|
10
|
+
nixampUserId: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
since: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A stream somebody could pick: a channel on one of their servers, as a link a classroom accepts. */
|
|
16
|
+
export interface StreamPick {
|
|
17
|
+
server: string;
|
|
18
|
+
serverUrl: string;
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
kind: string;
|
|
22
|
+
listeners: number;
|
|
23
|
+
link: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServerStreams {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
url: string;
|
|
30
|
+
reachable: boolean;
|
|
31
|
+
playing: boolean;
|
|
32
|
+
nowPlaying: string;
|
|
33
|
+
/** A link to what the server itself is playing, when it is. */
|
|
34
|
+
live: string;
|
|
35
|
+
channels: StreamPick[];
|
|
36
|
+
}
|