nixamp 0.1.0 → 0.3.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/README.md +225 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +72 -0
- package/dist/connections.js +128 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +103 -6
- package/dist/manage.js +30 -7
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +117 -4
- package/dist/server.js +923 -24
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +74 -0
- package/dist/share.js +172 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/admin.ts +243 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +158 -0
- package/src/daemon.ts +193 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +107 -6
- package/src/manage.ts +35 -7
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +68 -2
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1087 -23
- package/src/session.ts +209 -0
- package/src/share.ts +193 -0
- package/src/sources.ts +136 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/src/server.ts
CHANGED
|
@@ -11,14 +11,55 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createReadStream, statSync } from "node:fs";
|
|
13
13
|
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
14
|
-
import { networkInterfaces } from "node:os";
|
|
14
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { Connections, type Kind } from "./connections.ts";
|
|
18
|
+
import {
|
|
19
|
+
Broadcaster,
|
|
20
|
+
DEFAULT_ENCODER,
|
|
21
|
+
type Destination,
|
|
22
|
+
type EncoderSettings,
|
|
23
|
+
PRESETS,
|
|
24
|
+
redact,
|
|
25
|
+
} from "./broadcast.ts";
|
|
26
|
+
import { Ingest, normaliseFormat } from "./ingest.ts";
|
|
27
|
+
import { Channels, cleanId } from "./channels.ts";
|
|
28
|
+
import { RtmpListeners } from "./rtmp-in.ts";
|
|
29
|
+
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
30
|
+
import { needsAdmin, Owner } from "./owner.ts";
|
|
31
|
+
import { readSession } from "./session.ts";
|
|
32
|
+
import { Directory, parseAnnouncement } from "./directory.ts";
|
|
33
|
+
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
34
|
+
import {
|
|
35
|
+
applyRemoteConfig,
|
|
36
|
+
createPaywall,
|
|
37
|
+
FREE_LISTENERS,
|
|
38
|
+
type PaywallConfig,
|
|
39
|
+
paywallFromEnv,
|
|
40
|
+
} from "./paywall.ts";
|
|
41
|
+
import { isRemote } from "./sources.ts";
|
|
42
|
+
import {
|
|
43
|
+
allowedForListening,
|
|
44
|
+
elevate,
|
|
45
|
+
firewallInUse,
|
|
46
|
+
keyCookie,
|
|
47
|
+
keyFrom,
|
|
48
|
+
keysMatch,
|
|
49
|
+
newKey,
|
|
50
|
+
portCommands,
|
|
51
|
+
reachableAddresses,
|
|
52
|
+
scopeOf,
|
|
53
|
+
shareLink,
|
|
54
|
+
} from "./share.ts";
|
|
15
55
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
56
|
+
import { fileURLToPath } from "node:url";
|
|
16
57
|
import {
|
|
17
58
|
detectTools, peaks, RATE, Stream, toMono,
|
|
18
59
|
type Tools, type Track,
|
|
19
60
|
} from "./audio.ts";
|
|
20
61
|
import { Analyser, bandEdges, bands, decay } from "./fft.ts";
|
|
21
|
-
import {
|
|
62
|
+
import { loadSource } from "./playlist.ts";
|
|
22
63
|
import {
|
|
23
64
|
emptySnapshot, parseCommand,
|
|
24
65
|
type Command, type RemoteTrack, type Snapshot,
|
|
@@ -36,6 +77,57 @@ export interface ServeOptions {
|
|
|
36
77
|
web: string | null;
|
|
37
78
|
/** Stream the library's bytes to remotes. Off keeps the audio on this box. */
|
|
38
79
|
media: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Require the key from the share link. Off serves to anyone who can reach the
|
|
82
|
+
* port, which is what the public deployment wants and no private one does.
|
|
83
|
+
*/
|
|
84
|
+
key: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Ask the local firewall to let the port through, and put it back on the way
|
|
87
|
+
* out. Off by default because it changes the machine, not just this process.
|
|
88
|
+
*/
|
|
89
|
+
openPort: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Print one JSON line once listening. `nixamp daemon start` reads it rather
|
|
92
|
+
* than sleeping and hoping, so a daemon that failed to bind is reported as
|
|
93
|
+
* failed instead of started.
|
|
94
|
+
*/
|
|
95
|
+
announce: boolean;
|
|
96
|
+
/** Host the public directory. Only the deployment behind nixamp.com does. */
|
|
97
|
+
directory: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* List this stream at nixamp.com/directory. "ask" prompts, and is the
|
|
100
|
+
* default: publishing an address without being asked is not something a
|
|
101
|
+
* player gets to decide for you.
|
|
102
|
+
*/
|
|
103
|
+
publish: "ask" | "yes" | "no";
|
|
104
|
+
/** What to call it in the list. Defaults to this machine's hostname. */
|
|
105
|
+
name: string;
|
|
106
|
+
/**
|
|
107
|
+
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
108
|
+
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
109
|
+
*/
|
|
110
|
+
x402: boolean;
|
|
111
|
+
/** The account id that may administer this server, if not the signed-in one. */
|
|
112
|
+
owner: string;
|
|
113
|
+
/** Accept a live stream from a phone or a desktop, over HTTP. */
|
|
114
|
+
ingest: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Also listen for RTMP publishers on this port, which is what OBS, Larix and
|
|
117
|
+
* anything else native speaks. 0 means do not.
|
|
118
|
+
*/
|
|
119
|
+
rtmpIn: number;
|
|
120
|
+
/**
|
|
121
|
+
* How many RTMP publishers may be live at once. ffmpeg's listener serves one
|
|
122
|
+
* connection per process, so this is a port and a process each: 1935, 1936,
|
|
123
|
+
* and so on. HTTP publishers are not limited by this.
|
|
124
|
+
*/
|
|
125
|
+
rtmpStreams: number;
|
|
126
|
+
/**
|
|
127
|
+
* RTMP destinations, as `name=rtmp://host/app/key` or `youtube=key` for one
|
|
128
|
+
* of the presets. Repeatable.
|
|
129
|
+
*/
|
|
130
|
+
rtmp: string[];
|
|
39
131
|
}
|
|
40
132
|
|
|
41
133
|
/**
|
|
@@ -48,9 +140,24 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
48
140
|
const options: ServeOptions = {
|
|
49
141
|
root: ".",
|
|
50
142
|
port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
|
|
51
|
-
|
|
143
|
+
// Every interface, because a player nobody else can reach is not much of a
|
|
144
|
+
// remote. The key in the link is what makes that safe; --no-key gives up
|
|
145
|
+
// both at once, and --host pins it back to one address.
|
|
146
|
+
host: "0.0.0.0",
|
|
52
147
|
web: null,
|
|
53
148
|
media: true,
|
|
149
|
+
key: true,
|
|
150
|
+
openPort: false,
|
|
151
|
+
announce: false,
|
|
152
|
+
directory: false,
|
|
153
|
+
publish: "ask",
|
|
154
|
+
name: "",
|
|
155
|
+
x402: false,
|
|
156
|
+
owner: "",
|
|
157
|
+
ingest: false,
|
|
158
|
+
rtmpIn: 0,
|
|
159
|
+
rtmpStreams: 3,
|
|
160
|
+
rtmp: [],
|
|
54
161
|
};
|
|
55
162
|
let sawRoot = false;
|
|
56
163
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -73,6 +180,44 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
73
180
|
options.web = value();
|
|
74
181
|
} else if (arg === "--no-media") {
|
|
75
182
|
options.media = false;
|
|
183
|
+
} else if (arg === "--no-key") {
|
|
184
|
+
options.key = false;
|
|
185
|
+
} else if (arg === "--open-port") {
|
|
186
|
+
options.openPort = true;
|
|
187
|
+
} else if (arg === "--announce") {
|
|
188
|
+
options.announce = true;
|
|
189
|
+
} else if (arg === "--directory") {
|
|
190
|
+
options.directory = true;
|
|
191
|
+
} else if (arg === "--publish") {
|
|
192
|
+
options.publish = "yes";
|
|
193
|
+
} else if (arg === "--no-publish") {
|
|
194
|
+
options.publish = "no";
|
|
195
|
+
} else if (arg === "--name") {
|
|
196
|
+
options.name = value();
|
|
197
|
+
} else if (arg === "--owner") {
|
|
198
|
+
options.owner = value();
|
|
199
|
+
} else if (arg === "--ingest") {
|
|
200
|
+
options.ingest = true;
|
|
201
|
+
} else if (arg === "--rtmp-streams") {
|
|
202
|
+
const count = Number(value());
|
|
203
|
+
if (!Number.isInteger(count) || count < 1 || count > 16) {
|
|
204
|
+
throw new Error("nixamp serve: --rtmp-streams must be between 1 and 16");
|
|
205
|
+
}
|
|
206
|
+
options.rtmpStreams = count;
|
|
207
|
+
} else if (arg === "--rtmp-in") {
|
|
208
|
+
const port = Number(value());
|
|
209
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
210
|
+
throw new Error("nixamp serve: --rtmp-in must be a port number");
|
|
211
|
+
}
|
|
212
|
+
options.rtmpIn = port;
|
|
213
|
+
// Listening for RTMP is accepting a live stream, so it implies --ingest.
|
|
214
|
+
options.ingest = true;
|
|
215
|
+
} else if (arg === "--rtmp") {
|
|
216
|
+
options.rtmp.push(value());
|
|
217
|
+
} else if (arg === "--x402") {
|
|
218
|
+
options.x402 = true;
|
|
219
|
+
} else if (arg === "--no-x402") {
|
|
220
|
+
options.x402 = false;
|
|
76
221
|
} else if (arg.startsWith("-")) {
|
|
77
222
|
throw new Error(`nixamp serve: unknown option ${arg}`);
|
|
78
223
|
} else if (!sawRoot) {
|
|
@@ -93,6 +238,7 @@ const TYPES: Record<string, string> = {
|
|
|
93
238
|
// The installer, so `curl https://nixamp.com/install.sh` is readable rather
|
|
94
239
|
// than a download prompt.
|
|
95
240
|
".sh": "text/x-shellscript; charset=utf-8",
|
|
241
|
+
".ps1": "text/plain; charset=utf-8",
|
|
96
242
|
".svg": "image/svg+xml",
|
|
97
243
|
".png": "image/png",
|
|
98
244
|
".ico": "image/x-icon",
|
|
@@ -175,6 +321,12 @@ export interface Engine {
|
|
|
175
321
|
subscribe(listener: (snapshot: Snapshot) => void): () => void;
|
|
176
322
|
/** Absolute path of a track, or undefined when the index is not one. */
|
|
177
323
|
trackPath(index: number): string | undefined;
|
|
324
|
+
/**
|
|
325
|
+
* Play something else instead. Re-streaming is the whole reason the admin
|
|
326
|
+
* view exists: point a running server at a URL without restarting it and
|
|
327
|
+
* dropping every listener.
|
|
328
|
+
*/
|
|
329
|
+
replace(tracks: Track[], root: string): void;
|
|
178
330
|
stop(): void;
|
|
179
331
|
}
|
|
180
332
|
|
|
@@ -213,8 +365,8 @@ export class PlayerEngine implements Engine {
|
|
|
213
365
|
};
|
|
214
366
|
|
|
215
367
|
constructor(
|
|
216
|
-
private
|
|
217
|
-
private
|
|
368
|
+
private tracks: Track[],
|
|
369
|
+
private root: string,
|
|
218
370
|
tools: Tools,
|
|
219
371
|
/** Frames a second pushed to remotes. */
|
|
220
372
|
private readonly fps = 12,
|
|
@@ -364,6 +516,16 @@ export class PlayerEngine implements Engine {
|
|
|
364
516
|
}
|
|
365
517
|
this.listeners.clear();
|
|
366
518
|
}
|
|
519
|
+
|
|
520
|
+
replace(tracks: Track[], root: string): void {
|
|
521
|
+
this.stop();
|
|
522
|
+
this.tracks = tracks;
|
|
523
|
+
this.root = root;
|
|
524
|
+
this.state.index = 0;
|
|
525
|
+
this.state.position = 0;
|
|
526
|
+
this.state.note = "";
|
|
527
|
+
this.push();
|
|
528
|
+
}
|
|
367
529
|
}
|
|
368
530
|
|
|
369
531
|
/** An engine with no library behind it, for the hosted PWA. */
|
|
@@ -380,6 +542,7 @@ export class EmptyEngine implements Engine {
|
|
|
380
542
|
trackPath(): undefined {
|
|
381
543
|
return undefined;
|
|
382
544
|
}
|
|
545
|
+
replace(): void {}
|
|
383
546
|
stop(): void {}
|
|
384
547
|
}
|
|
385
548
|
|
|
@@ -420,6 +583,44 @@ export interface HandlerOptions {
|
|
|
420
583
|
web: string | null;
|
|
421
584
|
media: boolean;
|
|
422
585
|
version: string;
|
|
586
|
+
/** The key from the share link, or null to serve to anyone who can connect. */
|
|
587
|
+
key?: string | null;
|
|
588
|
+
/**
|
|
589
|
+
* A second key that may listen but not drive. The public directory hands
|
|
590
|
+
* this one out: a link that lets a stranger pause your music is not a link
|
|
591
|
+
* you can publish.
|
|
592
|
+
*/
|
|
593
|
+
listenKey?: string | null;
|
|
594
|
+
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
595
|
+
ffmpeg?: string[];
|
|
596
|
+
/** Who is listening, for the admin view. */
|
|
597
|
+
connections?: Connections;
|
|
598
|
+
/**
|
|
599
|
+
* How to turn a source into tracks, for re-streaming. Injected rather than
|
|
600
|
+
* imported so the handler stays a plain function of a request.
|
|
601
|
+
*/
|
|
602
|
+
load: (source: string) => Promise<Track[]>;
|
|
603
|
+
/**
|
|
604
|
+
* The public directory, on the instance that hosts one. Only nixamp.com
|
|
605
|
+
* passes this; a nixamp on your laptop is a publisher, not a registry.
|
|
606
|
+
*/
|
|
607
|
+
directory?: Directory;
|
|
608
|
+
/** Answers a request itself when listening has to be paid for. */
|
|
609
|
+
paywall?: (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
|
|
610
|
+
/** Live audio coming in from a phone or a desktop. */
|
|
611
|
+
ingest?: Ingest;
|
|
612
|
+
/** Several live streams at once, each with its own audience. */
|
|
613
|
+
channels?: Channels;
|
|
614
|
+
/** Live audio going out to RTMP. */
|
|
615
|
+
broadcaster?: Broadcaster;
|
|
616
|
+
/** Where a broadcast should send, and what it should look like. */
|
|
617
|
+
broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
|
|
618
|
+
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
619
|
+
accounts?: Accounts;
|
|
620
|
+
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
621
|
+
secureCookies?: boolean;
|
|
622
|
+
/** Who may administer this server. */
|
|
623
|
+
owner?: Owner;
|
|
423
624
|
}
|
|
424
625
|
|
|
425
626
|
/**
|
|
@@ -427,9 +628,34 @@ export interface HandlerOptions {
|
|
|
427
628
|
* drive it with a real socket and no ffmpeg in sight.
|
|
428
629
|
*/
|
|
429
630
|
export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
631
|
+
const tracker = options.connections ?? new Connections();
|
|
632
|
+
const started = Date.now();
|
|
633
|
+
|
|
634
|
+
/** Count a request in, count its bytes, and close it out exactly once. */
|
|
635
|
+
const watch = (
|
|
636
|
+
request: IncomingMessage,
|
|
637
|
+
response: ServerResponse,
|
|
638
|
+
kind: Kind,
|
|
639
|
+
track: string,
|
|
640
|
+
): void => {
|
|
641
|
+
const { id } = tracker.open(request, kind, track);
|
|
642
|
+
const write = response.write.bind(response);
|
|
643
|
+
response.write = ((chunk: unknown, ...rest: unknown[]) => {
|
|
644
|
+
if (typeof chunk === "string" || chunk instanceof Uint8Array) {
|
|
645
|
+
tracker.add(id, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength);
|
|
646
|
+
}
|
|
647
|
+
return (write as (...args: unknown[]) => boolean)(chunk, ...rest);
|
|
648
|
+
}) as typeof response.write;
|
|
649
|
+
// 'close' fires for a finished response and for a listener that walked
|
|
650
|
+
// away, which are the same thing as far as "is it still going" goes.
|
|
651
|
+
response.once("close", () => tracker.close(id));
|
|
652
|
+
};
|
|
653
|
+
|
|
430
654
|
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
431
655
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
432
656
|
const path = url.pathname;
|
|
657
|
+
const key = options.key ?? null;
|
|
658
|
+
const listenKey = options.listenKey ?? null;
|
|
433
659
|
|
|
434
660
|
if (request.method === "OPTIONS") {
|
|
435
661
|
response.writeHead(204, CORS);
|
|
@@ -437,17 +663,406 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
437
663
|
return;
|
|
438
664
|
}
|
|
439
665
|
|
|
666
|
+
// Opening a share link is what hands a browser its key. It comes back as a
|
|
667
|
+
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
668
|
+
// without the page knowing anything about keys. Either key works here, and
|
|
669
|
+
// which one was used decides what the browser can then do.
|
|
670
|
+
if (key !== null && path.startsWith("/s/")) {
|
|
671
|
+
const offered = decodeURIComponent(path.slice("/s/".length));
|
|
672
|
+
if (scopeOf(offered, key, listenKey) === null) {
|
|
673
|
+
json(response, 404, { error: "not found" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
|
|
677
|
+
response.end();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// /api/health answers unauthenticated on purpose: it is how you check the
|
|
682
|
+
// port is open from another device before wondering whether the link is
|
|
683
|
+
// wrong, and it says nothing about the library.
|
|
684
|
+
if (
|
|
685
|
+
key !== null &&
|
|
686
|
+
path !== "/api/health" &&
|
|
687
|
+
path !== "/api/directory" &&
|
|
688
|
+
!path.startsWith("/api/v1/auth/")
|
|
689
|
+
) {
|
|
690
|
+
const scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
691
|
+
if (scope === null) {
|
|
692
|
+
json(response, 401, { error: "this nixamp needs the key from its share link" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (scope === "listen" && !allowedForListening(path)) {
|
|
696
|
+
json(response, 403, { error: "this link can listen, not drive" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
440
701
|
if (path === "/api/health") {
|
|
441
702
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
442
703
|
return;
|
|
443
704
|
}
|
|
444
705
|
|
|
706
|
+
// --- accounts ---------------------------------------------------------
|
|
707
|
+
//
|
|
708
|
+
// Before the share-key check, because signing in is how somebody without a
|
|
709
|
+
// key becomes somebody with one. The API is versioned and namespaced the
|
|
710
|
+
// way the rest of the fleet's is.
|
|
711
|
+
if (path.startsWith("/api/v1/auth/") && options.accounts) {
|
|
712
|
+
const accounts = options.accounts;
|
|
713
|
+
const secure = options.secureCookies ?? false;
|
|
714
|
+
|
|
715
|
+
if (path === "/api/v1/auth/me") {
|
|
716
|
+
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
717
|
+
if (who === null) {
|
|
718
|
+
json(response, 401, { error: "not signed in" });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
json(response, 200, { account: who });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (path === "/api/v1/auth/logout") {
|
|
726
|
+
response.writeHead(200, {
|
|
727
|
+
...CORS,
|
|
728
|
+
"content-type": "application/json; charset=utf-8",
|
|
729
|
+
"set-cookie": clearedCookie(),
|
|
730
|
+
});
|
|
731
|
+
response.end(JSON.stringify({ ok: true }));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const signingUp = path === "/api/v1/auth/signup";
|
|
736
|
+
if (!signingUp && path !== "/api/v1/auth/login") {
|
|
737
|
+
json(response, 404, { error: "no such endpoint" });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (request.method !== "POST") {
|
|
741
|
+
json(response, 405, { error: "POST only" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let body: { email?: unknown; password?: unknown };
|
|
746
|
+
try {
|
|
747
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
748
|
+
} catch {
|
|
749
|
+
json(response, 400, { error: "bad JSON" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const result = signingUp
|
|
754
|
+
? await accounts.signUp(body.email, body.password)
|
|
755
|
+
: await accounts.signIn(body.email, body.password);
|
|
756
|
+
|
|
757
|
+
if (!result.ok) {
|
|
758
|
+
// 409 for an address that is taken, 401 for credentials that are not.
|
|
759
|
+
json(response, signingUp ? 409 : 401, { error: result.error });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// The token goes back in the body for the CLI and the desktop app, and
|
|
764
|
+
// as a cookie for the browser, which then needs to know nothing about it.
|
|
765
|
+
response.writeHead(200, {
|
|
766
|
+
...CORS,
|
|
767
|
+
"content-type": "application/json; charset=utf-8",
|
|
768
|
+
"set-cookie": sessionCookie(result.token, secure),
|
|
769
|
+
});
|
|
770
|
+
response.end(JSON.stringify({ account: result.account, token: result.token }));
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// The directory is public in both directions: anyone may read the list,
|
|
775
|
+
// and anyone running a nixamp may add themselves to it. It is answered
|
|
776
|
+
// before the key check, because a visitor to nixamp.com has no key and is
|
|
777
|
+
// exactly who it is for.
|
|
778
|
+
if (path === "/api/directory" && options.directory) {
|
|
779
|
+
if (request.method === "GET") {
|
|
780
|
+
json(response, 200, { streams: options.directory.list(), now: Date.now() });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (request.method === "POST") {
|
|
784
|
+
let announcement;
|
|
785
|
+
try {
|
|
786
|
+
announcement = parseAnnouncement(JSON.parse(await readBody(request)));
|
|
787
|
+
} catch {
|
|
788
|
+
json(response, 400, { error: "bad JSON" });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (announcement === null) {
|
|
792
|
+
json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
json(response, 200, options.directory.announce(announcement));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (request.method === "DELETE") {
|
|
799
|
+
const id = url.searchParams.get("id");
|
|
800
|
+
if (id) options.directory.withdraw(id);
|
|
801
|
+
json(response, 200, { ok: true });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Administering is a different question from listening, and it is asked
|
|
809
|
+
// after the share key: the control key answers both, but a listen key or a
|
|
810
|
+
// nixamp.com session answers only one of them.
|
|
811
|
+
if (options.owner && needsAdmin(path, request.method ?? "GET")) {
|
|
812
|
+
// A server started with --no-key has said that anyone who can reach the
|
|
813
|
+
// port may drive it, and prints exactly that. Locking administration to
|
|
814
|
+
// nobody would contradict it and leave such a server unadministrable.
|
|
815
|
+
const holdsControl =
|
|
816
|
+
key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
817
|
+
const check = await options.owner.check(holdsControl, tokenFrom(request.headers));
|
|
818
|
+
|
|
819
|
+
if (path === "/api/admin") {
|
|
820
|
+
// Always answered, and honestly: the page has to know whether to draw
|
|
821
|
+
// an admin panel at all, and "no" is a real answer rather than a 403.
|
|
822
|
+
json(response, 200, { allowed: check.allowed, as: check.as, claimed: options.owner.claimed });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (!check.allowed) {
|
|
826
|
+
json(response, 403, {
|
|
827
|
+
error: options.owner.claimed
|
|
828
|
+
? "sign in to nixamp.com as this server's owner, or use its control link"
|
|
829
|
+
: "this server has no owner signed in; use its control link",
|
|
830
|
+
});
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// After the key check: a paying listener still needs the link, and a 402
|
|
836
|
+
// is a worse answer than a 401 to someone who has neither.
|
|
837
|
+
if (options.paywall && (await options.paywall(request, response, path))) return;
|
|
838
|
+
|
|
839
|
+
// --- several streams at once ------------------------------------------
|
|
840
|
+
//
|
|
841
|
+
// A channel is one publisher and everybody listening to them. Two or three
|
|
842
|
+
// devices can publish at once, each to their own channel, and a listener
|
|
843
|
+
// picks which to hear.
|
|
844
|
+
if (path === "/api/channels" && options.channels) {
|
|
845
|
+
json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Publishing. Anyone with the control link may; listening to the result is
|
|
850
|
+
// open to whoever has the share link, like the rest of the audio.
|
|
851
|
+
if (path.startsWith("/api/channels/") && options.channels) {
|
|
852
|
+
const channels = options.channels;
|
|
853
|
+
const rest = path.slice("/api/channels/".length);
|
|
854
|
+
const [rawId, action] = rest.split("/");
|
|
855
|
+
const id = cleanId(rawId);
|
|
856
|
+
|
|
857
|
+
if (action === undefined && request.method === "GET") {
|
|
858
|
+
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
859
|
+
// produces for this channel is written to it until one end goes away.
|
|
860
|
+
const detach = channels.listen(id, response);
|
|
861
|
+
if (detach === null) {
|
|
862
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
watch(request, response, "stream", id);
|
|
866
|
+
response.writeHead(200, {
|
|
867
|
+
...CORS,
|
|
868
|
+
"content-type": "audio/mpeg",
|
|
869
|
+
"cache-control": "no-store",
|
|
870
|
+
});
|
|
871
|
+
const leave = (): void => detach();
|
|
872
|
+
request.on("close", leave);
|
|
873
|
+
response.on("close", leave);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (action === undefined && request.method === "DELETE") {
|
|
878
|
+
// Asked once: the second call would answer false, having just stopped
|
|
879
|
+
// the thing it was asking about.
|
|
880
|
+
const stopped = channels.stop(id);
|
|
881
|
+
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (request.method !== "POST") {
|
|
886
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
891
|
+
if (format === null) {
|
|
892
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const name = url.searchParams.get("name") ?? "";
|
|
896
|
+
|
|
897
|
+
// A chunked publisher sends many requests to one channel, so the first
|
|
898
|
+
// claims it and the rest feed what is already there.
|
|
899
|
+
if (action === "chunk") {
|
|
900
|
+
if (!channels.has(id) && channels.publish(id, name, format, "http") === null) {
|
|
901
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const chunks: Buffer[] = [];
|
|
905
|
+
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
906
|
+
channels.writeTo(id, Buffer.concat(chunks));
|
|
907
|
+
json(response, 200, { ok: true });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const claimed = channels.publish(id, name, format, "http");
|
|
912
|
+
if (claimed === null) {
|
|
913
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
await claimed.pump(request);
|
|
918
|
+
} catch {
|
|
919
|
+
// A publisher that hung up is not an error worth a 500.
|
|
920
|
+
}
|
|
921
|
+
claimed.close();
|
|
922
|
+
json(response, 200, { ok: true, bytes: claimed.info.bytes });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// --- streaming in ---------------------------------------------------
|
|
927
|
+
//
|
|
928
|
+
// Both shapes write into the same ffmpeg, so everything downstream cannot
|
|
929
|
+
// tell which one a sender used.
|
|
930
|
+
if (path === "/api/ingest" && options.ingest) {
|
|
931
|
+
if (request.method === "GET") {
|
|
932
|
+
json(response, 200, options.ingest.status());
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (request.method === "DELETE") {
|
|
936
|
+
options.ingest.close();
|
|
937
|
+
json(response, 200, { ok: true });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (request.method !== "POST") {
|
|
941
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
946
|
+
if (format === null) {
|
|
947
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const session = options.ingest.open(url.searchParams.get("name") ?? "", format);
|
|
952
|
+
if (session === null) {
|
|
953
|
+
// Somebody else is already broadcasting, which is a different problem
|
|
954
|
+
// from the request being wrong.
|
|
955
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
try {
|
|
960
|
+
await options.ingest.pump(request);
|
|
961
|
+
} catch {
|
|
962
|
+
// A sender that hung up is not an error worth a 500.
|
|
963
|
+
}
|
|
964
|
+
options.ingest.close();
|
|
965
|
+
json(response, 200, { ok: true, bytes: session.bytes });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// A browser cannot stream a request body over plain HTTP/1.1, so a phone
|
|
970
|
+
// sends its recording a chunk at a time instead.
|
|
971
|
+
if (path === "/api/ingest/chunk" && options.ingest) {
|
|
972
|
+
if (request.method !== "POST") {
|
|
973
|
+
json(response, 405, { error: "POST only" });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (!options.ingest.live) {
|
|
977
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
978
|
+
if (format === null) {
|
|
979
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (options.ingest.open(url.searchParams.get("name") ?? "", format) === null) {
|
|
983
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const chunks: Buffer[] = [];
|
|
988
|
+
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
989
|
+
options.ingest.write(Buffer.concat(chunks));
|
|
990
|
+
json(response, 200, options.ingest.status());
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// --- broadcasting out -------------------------------------------------
|
|
995
|
+
if (path === "/api/broadcast" && options.broadcaster) {
|
|
996
|
+
if (request.method === "GET") {
|
|
997
|
+
json(response, 200, options.broadcaster.status());
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (request.method === "DELETE") {
|
|
1001
|
+
options.broadcaster.stop();
|
|
1002
|
+
json(response, 200, options.broadcaster.status());
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (request.method !== "POST") {
|
|
1006
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let source = "";
|
|
1011
|
+
try {
|
|
1012
|
+
source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
|
|
1013
|
+
} catch {
|
|
1014
|
+
json(response, 400, { error: "bad JSON" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const current = engine.snapshot();
|
|
1018
|
+
const chosen = source || engine.trackPath(current.index) || "";
|
|
1019
|
+
if (!chosen) {
|
|
1020
|
+
json(response, 422, { error: "nothing to broadcast" });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const plan = options.broadcast?.() ?? { destinations: [], settings: DEFAULT_ENCODER };
|
|
1025
|
+
const started = options.broadcaster.start({
|
|
1026
|
+
source: chosen,
|
|
1027
|
+
destinations: plan.destinations,
|
|
1028
|
+
settings: plan.settings,
|
|
1029
|
+
webAudio: false,
|
|
1030
|
+
// Music has no picture, and RTMP platforms insist on a video track.
|
|
1031
|
+
needsVideo: true,
|
|
1032
|
+
});
|
|
1033
|
+
if (!started.ok) {
|
|
1034
|
+
json(response, 422, { error: started.error });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
json(response, 200, options.broadcaster.status());
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Names and URLs, never a key.
|
|
1042
|
+
if (path === "/api/broadcast/destinations" && options.broadcast) {
|
|
1043
|
+
json(response, 200, { destinations: options.broadcast().destinations.map(redact) });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
445
1047
|
if (path === "/api/state") {
|
|
446
1048
|
json(response, 200, engine.snapshot());
|
|
447
1049
|
return;
|
|
448
1050
|
}
|
|
449
1051
|
|
|
1052
|
+
// Everything the admin view draws, in one request: who is connected, and
|
|
1053
|
+
// what this server is.
|
|
1054
|
+
if (path === "/api/connections") {
|
|
1055
|
+
json(response, 200, {
|
|
1056
|
+
connections: tracker.list(),
|
|
1057
|
+
active: tracker.active,
|
|
1058
|
+
startedAt: started,
|
|
1059
|
+
now: Date.now(),
|
|
1060
|
+
});
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
450
1064
|
if (path === "/api/events") {
|
|
1065
|
+
watch(request, response, "events", "");
|
|
451
1066
|
response.writeHead(200, {
|
|
452
1067
|
...CORS,
|
|
453
1068
|
"content-type": "text/event-stream; charset=utf-8",
|
|
@@ -494,6 +1109,38 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
494
1109
|
return;
|
|
495
1110
|
}
|
|
496
1111
|
|
|
1112
|
+
// Re-stream: hand the running server a different source. The listeners
|
|
1113
|
+
// stay connected; what they are listening to changes under them.
|
|
1114
|
+
if (path === "/api/source") {
|
|
1115
|
+
if (request.method !== "POST") {
|
|
1116
|
+
json(response, 405, { error: "POST only" });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
let source = "";
|
|
1120
|
+
try {
|
|
1121
|
+
source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
|
|
1122
|
+
} catch {
|
|
1123
|
+
json(response, 400, { error: "bad JSON" });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (!source) {
|
|
1127
|
+
json(response, 400, { error: "no source given" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
const tracks = await options.load(source);
|
|
1132
|
+
if (tracks.length === 0) {
|
|
1133
|
+
json(response, 422, { error: `nothing to play at ${source}` });
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
engine.replace(tracks, source);
|
|
1137
|
+
json(response, 200, engine.snapshot());
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
|
|
1140
|
+
}
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
497
1144
|
if (path.startsWith("/api/media/")) {
|
|
498
1145
|
if (!options.media) {
|
|
499
1146
|
json(response, 403, { error: "media streaming is off" });
|
|
@@ -505,10 +1152,31 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
505
1152
|
json(response, 404, { error: "no such track" });
|
|
506
1153
|
return;
|
|
507
1154
|
}
|
|
1155
|
+
watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
|
|
508
1156
|
sendFile(request, response, file);
|
|
509
1157
|
return;
|
|
510
1158
|
}
|
|
511
1159
|
|
|
1160
|
+
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
1161
|
+
// a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
|
|
1162
|
+
// the bytes on as they arrive, so a live stream starts immediately rather
|
|
1163
|
+
// than after it ends, which for a live stream is never.
|
|
1164
|
+
if (path.startsWith("/api/stream/")) {
|
|
1165
|
+
const index = Number(path.slice("/api/stream/".length));
|
|
1166
|
+
const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
|
|
1167
|
+
if (source === undefined) {
|
|
1168
|
+
json(response, 404, { error: "no such track" });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (!options.media) {
|
|
1172
|
+
json(response, 403, { error: "media streaming is off" });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
|
|
1176
|
+
transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
512
1180
|
if (path.startsWith("/api/")) {
|
|
513
1181
|
json(response, 404, { error: "no such endpoint" });
|
|
514
1182
|
return;
|
|
@@ -535,6 +1203,101 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
535
1203
|
};
|
|
536
1204
|
}
|
|
537
1205
|
|
|
1206
|
+
/** Read a file, or null. The firewall probe asks about files it may not have. */
|
|
1207
|
+
function readIfPossible(path: string): string | null {
|
|
1208
|
+
try {
|
|
1209
|
+
return readFileSync(path, "utf8");
|
|
1210
|
+
} catch {
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Decode anything and hand back MP3, as it is produced.
|
|
1217
|
+
*
|
|
1218
|
+
* No seeking: this is a pipe, and the length is not known until it ends. The
|
|
1219
|
+
* player falls back to /api/media for a local file it can seek, and uses this
|
|
1220
|
+
* for everything else.
|
|
1221
|
+
*/
|
|
1222
|
+
function transcode(
|
|
1223
|
+
request: IncomingMessage,
|
|
1224
|
+
response: ServerResponse,
|
|
1225
|
+
source: string,
|
|
1226
|
+
ffmpeg: string[],
|
|
1227
|
+
): void {
|
|
1228
|
+
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
1229
|
+
const child = spawn(
|
|
1230
|
+
command,
|
|
1231
|
+
[
|
|
1232
|
+
...prefix,
|
|
1233
|
+
"-hide_banner",
|
|
1234
|
+
"-loglevel", "error",
|
|
1235
|
+
// Reconnect through the sort of hiccup a long stream runs into. These
|
|
1236
|
+
// belong to the http protocol, and ffmpeg rejects the whole command
|
|
1237
|
+
// when they are handed to it for a file on disk.
|
|
1238
|
+
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1239
|
+
"-i", source,
|
|
1240
|
+
"-vn",
|
|
1241
|
+
"-f", "mp3",
|
|
1242
|
+
"-b:a", "192k",
|
|
1243
|
+
"-",
|
|
1244
|
+
],
|
|
1245
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1246
|
+
);
|
|
1247
|
+
|
|
1248
|
+
let failed = "";
|
|
1249
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
1250
|
+
// Keep the tail: ffmpeg says what went wrong on its last line.
|
|
1251
|
+
failed = (failed + chunk.toString()).slice(-2000);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
let started = false;
|
|
1255
|
+
const begin = (): void => {
|
|
1256
|
+
if (started) return;
|
|
1257
|
+
started = true;
|
|
1258
|
+
response.writeHead(200, {
|
|
1259
|
+
...CORS,
|
|
1260
|
+
"content-type": "audio/mpeg",
|
|
1261
|
+
"cache-control": "no-store",
|
|
1262
|
+
// Length is unknowable up front, and a browser is happy without it.
|
|
1263
|
+
"transfer-encoding": "chunked",
|
|
1264
|
+
});
|
|
1265
|
+
};
|
|
1266
|
+
// Wait for a first byte before promising success. ffmpeg rejects a bad option
|
|
1267
|
+
// or a missing input immediately, and answering 200 with nothing looks the
|
|
1268
|
+
// same from a player as a track that is simply silent.
|
|
1269
|
+
child.stdout.once("data", begin);
|
|
1270
|
+
// Both ends can fail: a listener closing the tab breaks the socket under the
|
|
1271
|
+
// pipe, and an EPIPE nobody is listening for takes the process down.
|
|
1272
|
+
child.stdout.on("error", () => child.kill("SIGKILL"));
|
|
1273
|
+
response.on("error", () => child.kill("SIGKILL"));
|
|
1274
|
+
child.stdout.pipe(response);
|
|
1275
|
+
|
|
1276
|
+
child.on("error", (error) => {
|
|
1277
|
+
console.error(`nixamp: ffmpeg could not start: ${error.message}`);
|
|
1278
|
+
if (!response.headersSent) json(response, 500, { error: "ffmpeg could not start" });
|
|
1279
|
+
else response.end();
|
|
1280
|
+
});
|
|
1281
|
+
child.on("close", (code) => {
|
|
1282
|
+
const message = failed.trim();
|
|
1283
|
+
if (code !== 0 && code !== null) console.error(`nixamp: ffmpeg exited ${code}: ${message}`);
|
|
1284
|
+
if (!started) {
|
|
1285
|
+
// Nothing was ever produced, so the status can still tell the truth.
|
|
1286
|
+
json(response, 502, { error: "could not decode that source", detail: message.split("\n").pop() ?? "" });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
response.end();
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// A listener that closes the tab should not leave an ffmpeg decoding into
|
|
1293
|
+
// nothing for the rest of the album.
|
|
1294
|
+
const stop = (): void => {
|
|
1295
|
+
child.kill("SIGKILL");
|
|
1296
|
+
};
|
|
1297
|
+
request.on("close", stop);
|
|
1298
|
+
response.on("close", stop);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
538
1301
|
function isFile(path: string): boolean {
|
|
539
1302
|
try {
|
|
540
1303
|
return statSync(path).isFile();
|
|
@@ -592,38 +1355,281 @@ export function createServer(engine: Engine, options: HandlerOptions): Server {
|
|
|
592
1355
|
});
|
|
593
1356
|
}
|
|
594
1357
|
|
|
595
|
-
/** Where a remote on another device should point its browser. */
|
|
596
|
-
export function addressesFor(host: string, port: number): string[] {
|
|
597
|
-
if (host !== "0.0.0.0" && host !== "::") return [`http://${host}:${port}`];
|
|
598
|
-
const out = [`http://localhost:${port}`];
|
|
599
|
-
for (const entries of Object.values(networkInterfaces())) {
|
|
600
|
-
for (const entry of entries ?? []) {
|
|
601
|
-
if (entry.family === "IPv4" && !entry.internal) out.push(`http://${entry.address}:${port}`);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
return out;
|
|
605
|
-
}
|
|
606
1358
|
|
|
607
1359
|
export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
608
1360
|
const options = parseServeArgs(argv);
|
|
609
|
-
const root = resolve(options.root);
|
|
1361
|
+
const root = isRemote(options.root) ? options.root : resolve(options.root);
|
|
610
1362
|
const tools = detectTools();
|
|
611
|
-
const tracks =
|
|
1363
|
+
const tracks = await loadSource(tools, root);
|
|
612
1364
|
const engine: Engine = tracks.length > 0
|
|
613
1365
|
? new PlayerEngine(tracks, root, tools)
|
|
614
1366
|
: new EmptyEngine(`No audio files under ${root}.`);
|
|
615
1367
|
|
|
616
1368
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
617
|
-
const
|
|
1369
|
+
const key = options.key ? newKey() : null;
|
|
1370
|
+
// Minted whether or not it is published, so `nixamp admin` and the operator
|
|
1371
|
+
// both have a link they can hand out without handing over the controls.
|
|
1372
|
+
const listenKey = key === null ? null : newKey();
|
|
1373
|
+
// Configuration can arrive from the directory later, so it is a box the
|
|
1374
|
+
// paywall reads rather than a value it was handed once.
|
|
1375
|
+
let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
1376
|
+
const connections = new Connections();
|
|
1377
|
+
const paywall = createPaywall({
|
|
1378
|
+
config: () => paywallConfig,
|
|
1379
|
+
liveListeners: () => connections.listening,
|
|
1380
|
+
// The address a payer can actually reach: the public one where there is
|
|
1381
|
+
// one, since a quote pointing at 192.168.1.5 is one they cannot pay from.
|
|
1382
|
+
siteUrl: () => {
|
|
1383
|
+
const bound = server.address();
|
|
1384
|
+
const live = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
1385
|
+
const reachable = reachableAddresses(options.host, live);
|
|
1386
|
+
return (reachable.find((a) => a.label === "on the internet") ?? reachable[0])?.url
|
|
1387
|
+
?? `http://127.0.0.1:${live}`;
|
|
1388
|
+
},
|
|
1389
|
+
// The operator drives with the control key, and is not a customer.
|
|
1390
|
+
exempt: (request) =>
|
|
1391
|
+
key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
const channels = new Channels({
|
|
1395
|
+
ffmpeg: tools.ffmpeg,
|
|
1396
|
+
onStart: (info) =>
|
|
1397
|
+
console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
1398
|
+
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
const destinations = parseDestinations(options.rtmp);
|
|
1402
|
+
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
1403
|
+
const ingest = options.ingest
|
|
1404
|
+
? new Ingest({
|
|
1405
|
+
ffmpeg: tools.ffmpeg,
|
|
1406
|
+
sink: "pipe:1",
|
|
1407
|
+
onStart: (session) => console.log(` ${session.name} started streaming in (${session.format}).`),
|
|
1408
|
+
onEnd: (session, error) =>
|
|
1409
|
+
console.log(` ${session.name} stopped streaming in${error ? `: ${error}` : ""}.`),
|
|
1410
|
+
})
|
|
1411
|
+
: undefined;
|
|
1412
|
+
|
|
1413
|
+
// The account signed in on this machine owns the server it starts. That is
|
|
1414
|
+
// the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
|
|
1415
|
+
// pocket can administer it from anywhere by signing in as the same person.
|
|
1416
|
+
const session = readSession();
|
|
1417
|
+
const owner = new Owner({
|
|
1418
|
+
ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
|
|
1419
|
+
site: session?.site ?? DEFAULT_DIRECTORY,
|
|
1420
|
+
});
|
|
618
1421
|
|
|
619
|
-
|
|
1422
|
+
const server = createServer(engine, {
|
|
1423
|
+
web,
|
|
1424
|
+
media: options.media,
|
|
1425
|
+
owner,
|
|
1426
|
+
channels,
|
|
1427
|
+
...(ingest ? { ingest } : {}),
|
|
1428
|
+
broadcaster,
|
|
1429
|
+
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
1430
|
+
version,
|
|
1431
|
+
key,
|
|
1432
|
+
listenKey,
|
|
1433
|
+
connections,
|
|
1434
|
+
paywall,
|
|
1435
|
+
ffmpeg: tools.ffmpeg,
|
|
1436
|
+
load: (next) => loadSource(tools, next),
|
|
1437
|
+
...(options.directory ? { directory: new Directory() } : {}),
|
|
1438
|
+
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
1439
|
+
// laptop has nobody to be an account of.
|
|
1440
|
+
...(options.directory && process.env["DATABASE_URL"]
|
|
1441
|
+
? {
|
|
1442
|
+
accounts: new Accounts({
|
|
1443
|
+
connectionString: process.env["DATABASE_URL"],
|
|
1444
|
+
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
1445
|
+
}),
|
|
1446
|
+
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
1447
|
+
}
|
|
1448
|
+
: {}),
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
// A port already in use is the most ordinary failure there is, and it
|
|
1452
|
+
// arrives as an unhandled 'error' event that takes the process down with a
|
|
1453
|
+
// stack trace nobody reads.
|
|
1454
|
+
await new Promise<void>((done, fail) => {
|
|
1455
|
+
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
1456
|
+
fail(
|
|
1457
|
+
new Error(
|
|
1458
|
+
error.code === "EADDRINUSE"
|
|
1459
|
+
? `nixamp: port ${options.port} is already in use. Pass --port to pick another.`
|
|
1460
|
+
: error.code === "EACCES"
|
|
1461
|
+
? `nixamp: not allowed to listen on port ${options.port}. Ports below 1024 need root.`
|
|
1462
|
+
: `nixamp: could not listen on ${options.host}:${options.port}: ${error.message}`,
|
|
1463
|
+
),
|
|
1464
|
+
);
|
|
1465
|
+
});
|
|
1466
|
+
server.listen(options.port, options.host, done);
|
|
1467
|
+
});
|
|
620
1468
|
const bound = server.address();
|
|
621
1469
|
const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
1470
|
+
|
|
1471
|
+
const io = {
|
|
1472
|
+
read: readIfPossible,
|
|
1473
|
+
run: (command: string, args: string[]) => {
|
|
1474
|
+
const done = spawnSync(command, args, { encoding: "utf8" });
|
|
1475
|
+
return { status: done.status, stdout: done.stdout ?? "" };
|
|
1476
|
+
},
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
if (options.announce) {
|
|
1480
|
+
console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
622
1483
|
console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
|
|
623
|
-
|
|
624
|
-
|
|
1484
|
+
console.log("");
|
|
1485
|
+
|
|
1486
|
+
// The link, not the address. Without the key the address is a 401, so
|
|
1487
|
+
// printing a bare host:port would be printing something that does not work.
|
|
1488
|
+
const addresses = reachableAddresses(options.host, port);
|
|
1489
|
+
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
1490
|
+
for (const { label, url } of addresses) {
|
|
1491
|
+
console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
console.log("");
|
|
1495
|
+
if (key === null) {
|
|
1496
|
+
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
1497
|
+
} else {
|
|
1498
|
+
console.log(" Open that link once on a phone or a laptop and it stays signed in.");
|
|
1499
|
+
console.log(` Anything without the key gets a 401. Key: ${key}`);
|
|
1500
|
+
if (listenKey !== null) {
|
|
1501
|
+
console.log("");
|
|
1502
|
+
console.log(" A listen-only link, for someone you want to hear it but not drive it:");
|
|
1503
|
+
for (const { label, url } of addresses) {
|
|
1504
|
+
if (label === "here") continue;
|
|
1505
|
+
console.log(` ${shareLink(url, listenKey)}`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (addresses.some((a) => a.label === "on the internet")) {
|
|
1510
|
+
console.log("");
|
|
1511
|
+
console.log(
|
|
1512
|
+
key === null
|
|
1513
|
+
? " The public address is open to anyone: --no-key means no key. --host 127.0.0.1 keeps it here."
|
|
1514
|
+
: " The public address works from anywhere, for anyone with the key. --host 127.0.0.1 keeps it here.",
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
if (!options.media) console.log(" Audio stays on this machine: --no-media is set.");
|
|
1518
|
+
if (owner.claimed) {
|
|
1519
|
+
console.log(` ${session?.email} can administer this from anywhere, signed in at ${session?.site}.`);
|
|
1520
|
+
}
|
|
1521
|
+
if (options.ingest) console.log(" Accepting a live stream in at POST /api/ingest.");
|
|
1522
|
+
let rtmp: RtmpListeners | null = null;
|
|
1523
|
+
if (options.rtmpIn > 0) {
|
|
1524
|
+
const publish = addresses.find((a) => a.label !== "here") ?? addresses[0];
|
|
1525
|
+
const host = publish ? new URL(publish.url).hostname : "127.0.0.1";
|
|
1526
|
+
// One listener per stream, because ffmpeg's RTMP listener serves a single
|
|
1527
|
+
// connection per process. Three devices going live at once is three ports.
|
|
1528
|
+
const slots = Array.from({ length: options.rtmpStreams }, (_, i) => ({
|
|
1529
|
+
port: options.rtmpIn + i,
|
|
1530
|
+
id: i === 0 ? "live" : `live-${i + 1}`,
|
|
1531
|
+
}));
|
|
1532
|
+
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
1533
|
+
rtmp.listen(slots);
|
|
1534
|
+
|
|
1535
|
+
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
1536
|
+
for (const slot of slots) {
|
|
1537
|
+
console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (destinations.length > 0) {
|
|
1541
|
+
console.log(` Ready to broadcast to ${destinations.map((d) => d.name).join(", ")}.`);
|
|
1542
|
+
}
|
|
1543
|
+
if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
1544
|
+
|
|
1545
|
+
// Listening on every interface proves the socket is open here and nothing
|
|
1546
|
+
// about the path between here and the phone.
|
|
1547
|
+
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
1548
|
+
const firewall = listening ? firewallInUse(io) : null;
|
|
1549
|
+
let closePort: (() => void) | null = null;
|
|
1550
|
+
|
|
1551
|
+
if (firewall !== null) {
|
|
1552
|
+
const { open, close } = portCommands(firewall, port);
|
|
1553
|
+
if (!options.openPort) {
|
|
1554
|
+
console.log("");
|
|
1555
|
+
console.log(` ${firewall} is running, so other devices cannot reach this port yet:`);
|
|
1556
|
+
console.log(` sudo ${open.join(" ")}`);
|
|
1557
|
+
console.log(" or start with --open-port and nixamp will do it, and undo it on exit.");
|
|
1558
|
+
} else {
|
|
1559
|
+
const elevated = elevate(io, open);
|
|
1560
|
+
if (elevated === null) {
|
|
1561
|
+
console.log("");
|
|
1562
|
+
console.log(` --open-port needs root or passwordless sudo. Run this yourself:`);
|
|
1563
|
+
console.log(` sudo ${open.join(" ")}`);
|
|
1564
|
+
} else {
|
|
1565
|
+
const done = spawnSync(elevated[0] as string, elevated.slice(1), { encoding: "utf8" });
|
|
1566
|
+
if (done.status === 0) {
|
|
1567
|
+
console.log("");
|
|
1568
|
+
console.log(` Opened ${port}/tcp in ${firewall}. It closes again when this exits.`);
|
|
1569
|
+
// Leave the machine as it was found. A player should not be the
|
|
1570
|
+
// reason a port is still open next week.
|
|
1571
|
+
closePort = () => {
|
|
1572
|
+
const undo = elevate(io, close);
|
|
1573
|
+
if (undo) spawnSync(undo[0] as string, undo.slice(1), { stdio: "ignore" });
|
|
1574
|
+
};
|
|
1575
|
+
} else {
|
|
1576
|
+
console.log("");
|
|
1577
|
+
console.log(` Could not open the port: ${(done.stderr || done.stdout || "").trim() || "unknown error"}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// The listing carries the listen link, and only ever a public address: an
|
|
1584
|
+
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
1585
|
+
const publishable_ = addresses.find((a) => a.label === "on the internet")
|
|
1586
|
+
?? addresses.find((a) => a.label === "on tailscale");
|
|
1587
|
+
let publisher: Publisher | null = null;
|
|
1588
|
+
|
|
1589
|
+
if (options.publish !== "no" && publishable_) {
|
|
1590
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
1591
|
+
const wanted = options.publish === "yes"
|
|
1592
|
+
? true
|
|
1593
|
+
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
1594
|
+
|
|
1595
|
+
if (wanted) {
|
|
1596
|
+
publisher = new Publisher({
|
|
1597
|
+
directory: DEFAULT_DIRECTORY,
|
|
1598
|
+
name: options.name || hostname(),
|
|
1599
|
+
url: listen,
|
|
1600
|
+
tracks: tracks.length,
|
|
1601
|
+
nowPlaying: () => {
|
|
1602
|
+
const snapshot = engine.snapshot();
|
|
1603
|
+
return snapshot.tracks[snapshot.index]?.title ?? "";
|
|
1604
|
+
},
|
|
1605
|
+
onConfig: (remote) => {
|
|
1606
|
+
const next = applyRemoteConfig(paywallConfig, (remote as { x402?: unknown })?.x402);
|
|
1607
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig)) return;
|
|
1608
|
+
paywallConfig = next;
|
|
1609
|
+
console.log(next.enabled
|
|
1610
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
1611
|
+
: " nixamp.com turned paid listening off.");
|
|
1612
|
+
},
|
|
1613
|
+
});
|
|
1614
|
+
const listing = await publisher.start();
|
|
1615
|
+
console.log("");
|
|
1616
|
+
console.log(listing
|
|
1617
|
+
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
|
1618
|
+
: ` Could not reach ${DEFAULT_DIRECTORY}; not listed.`);
|
|
1619
|
+
}
|
|
1620
|
+
} else if (options.publish === "yes" && !publishable_) {
|
|
1621
|
+
console.log("");
|
|
1622
|
+
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
1623
|
+
}
|
|
625
1624
|
|
|
626
1625
|
const shutdown = (): void => {
|
|
1626
|
+
rtmp?.stop();
|
|
1627
|
+
channels.stopAll();
|
|
1628
|
+
ingest?.stopRtmp();
|
|
1629
|
+
ingest?.close();
|
|
1630
|
+
broadcaster.stop();
|
|
1631
|
+
void publisher?.stop();
|
|
1632
|
+
closePort?.();
|
|
627
1633
|
engine.stop();
|
|
628
1634
|
server.close(() => process.exit(0));
|
|
629
1635
|
// A hung keep-alive should not outlive a ctrl-c.
|
|
@@ -633,11 +1639,69 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
633
1639
|
process.on("SIGTERM", shutdown);
|
|
634
1640
|
}
|
|
635
1641
|
|
|
1642
|
+
/**
|
|
1643
|
+
* `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
|
|
1644
|
+
*
|
|
1645
|
+
* A key is a password, so it is taken from the command line or the environment
|
|
1646
|
+
* and never from a request: a client that could name its own destination could
|
|
1647
|
+
* point your broadcast at itself.
|
|
1648
|
+
*/
|
|
1649
|
+
export function parseDestinations(specs: string[]): Destination[] {
|
|
1650
|
+
const out: Destination[] = [];
|
|
1651
|
+
for (const [index, spec] of specs.entries()) {
|
|
1652
|
+
const at = spec.indexOf("=");
|
|
1653
|
+
if (at <= 0) continue;
|
|
1654
|
+
const name = spec.slice(0, at).trim();
|
|
1655
|
+
const rest = spec.slice(at + 1).trim();
|
|
1656
|
+
if (!name || !rest) continue;
|
|
1657
|
+
|
|
1658
|
+
const preset = PRESETS[name.toLowerCase()];
|
|
1659
|
+
if (preset && !/^rtmps?:\/\//i.test(rest)) {
|
|
1660
|
+
out.push({ id: String(index + 1), name, url: preset, key: rest, enabled: true });
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (!/^rtmps?:\/\//i.test(rest)) continue;
|
|
1664
|
+
|
|
1665
|
+
// A full URL: the last path segment is the key.
|
|
1666
|
+
const cut = rest.lastIndexOf("/");
|
|
1667
|
+
if (cut <= "rtmp://".length) continue;
|
|
1668
|
+
out.push({
|
|
1669
|
+
id: String(index + 1),
|
|
1670
|
+
name,
|
|
1671
|
+
url: rest.slice(0, cut),
|
|
1672
|
+
key: rest.slice(cut + 1),
|
|
1673
|
+
enabled: true,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
return out;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Which account the signed-in session belongs to. Asked once at startup rather
|
|
1681
|
+
* than trusted from the file: a token that nixamp.com no longer accepts should
|
|
1682
|
+
* not confer ownership of anything.
|
|
1683
|
+
*/
|
|
1684
|
+
async function ownerIdOf(session: { site: string; token: string }): Promise<string> {
|
|
1685
|
+
try {
|
|
1686
|
+
const answer = await fetch(`${session.site}/api/v1/auth/me`, {
|
|
1687
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
1688
|
+
});
|
|
1689
|
+
if (!answer.ok) return "";
|
|
1690
|
+
const body = (await answer.json()) as { account?: { id?: string } };
|
|
1691
|
+
return typeof body.account?.id === "string" ? body.account.id : "";
|
|
1692
|
+
} catch {
|
|
1693
|
+
// Offline at startup means no remote administration until a restart, and
|
|
1694
|
+
// the control key still works. Better than claiming an owner we cannot
|
|
1695
|
+
// check.
|
|
1696
|
+
return "";
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
636
1700
|
/** The built PWA, when it is sitting next to us in the same install. */
|
|
637
1701
|
function defaultWebDir(): string | null {
|
|
638
1702
|
const fromEnv = process.env.NIXAMP_WEB_DIR;
|
|
639
1703
|
if (fromEnv && isFile(join(fromEnv, "index.html"))) return fromEnv;
|
|
640
|
-
const here = new URL(".", import.meta.url)
|
|
1704
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
641
1705
|
for (const guess of [
|
|
642
1706
|
join(here, "..", "web", "dist"),
|
|
643
1707
|
join(here, "..", "..", "web", "dist"),
|