nixamp 0.5.5 → 0.5.7
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/admin.d.ts +20 -0
- package/dist/admin.js +29 -3
- package/dist/guard.d.ts +69 -0
- package/dist/guard.js +103 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +42 -0
- package/package.json +1 -1
- package/src/admin.ts +43 -2
- package/src/guard.ts +132 -0
- package/src/server.ts +50 -0
- package/web/dist/assets/index-BD37tdcP.js +1 -0
- package/web/dist/index.html +8 -3
- package/web/dist/sw.js +2 -2
- package/web/dist/assets/index-pztl5rKf.js +0 -1
package/dist/admin.d.ts
CHANGED
|
@@ -28,6 +28,19 @@ interface Snapshot {
|
|
|
28
28
|
export interface AdminOptions {
|
|
29
29
|
url: string;
|
|
30
30
|
key: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Every address the server found, labelled, share key not yet applied.
|
|
33
|
+
*
|
|
34
|
+
* `url` is the one this process talks to, which for a local daemon is
|
|
35
|
+
* loopback -- correct for asking it questions and useless for handing to
|
|
36
|
+
* anybody. These are the ones worth reading off the screen.
|
|
37
|
+
*/
|
|
38
|
+
links: {
|
|
39
|
+
label: string;
|
|
40
|
+
url: string;
|
|
41
|
+
}[];
|
|
42
|
+
/** What it is serving, so the admin view says so without being asked. */
|
|
43
|
+
source: string;
|
|
31
44
|
}
|
|
32
45
|
/** Where to point, from the flags or from the daemon that is running. */
|
|
33
46
|
export declare function resolveTarget(argv: string[]): AdminOptions;
|
|
@@ -42,6 +55,13 @@ export interface View {
|
|
|
42
55
|
error: string;
|
|
43
56
|
typing: boolean;
|
|
44
57
|
restreaming: string;
|
|
58
|
+
/** Labelled addresses, and the key that makes them work. */
|
|
59
|
+
links: {
|
|
60
|
+
label: string;
|
|
61
|
+
url: string;
|
|
62
|
+
}[];
|
|
63
|
+
key: string | null;
|
|
64
|
+
source: string;
|
|
45
65
|
}
|
|
46
66
|
export declare function draw(ui: Container, theme: Theme, view: View): void;
|
|
47
67
|
export {};
|
package/dist/admin.js
CHANGED
|
@@ -14,13 +14,22 @@ export function resolveTarget(argv) {
|
|
|
14
14
|
const keyAt = argv.findIndex((a) => a === "--key");
|
|
15
15
|
const url = at === -1 ? null : argv[at + 1];
|
|
16
16
|
const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Pointed somewhere by hand, that address is the only one known.
|
|
18
|
+
if (url) {
|
|
19
|
+
const bare = url.replace(/\/+$/, "");
|
|
20
|
+
return { url: bare, key, links: [{ label: "there", url: bare }], source: "" };
|
|
21
|
+
}
|
|
19
22
|
const state = readState();
|
|
20
23
|
if (state === null) {
|
|
21
24
|
throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
|
|
22
25
|
}
|
|
23
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
url: daemonUrl(state),
|
|
28
|
+
key: key ?? state.key,
|
|
29
|
+
// A state file written before 0.5.3 has no list; loopback stands in.
|
|
30
|
+
links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
|
|
31
|
+
source: state.source,
|
|
32
|
+
};
|
|
24
33
|
}
|
|
25
34
|
/** Seconds as something a person reads at a glance. */
|
|
26
35
|
export function since(ms) {
|
|
@@ -118,6 +127,7 @@ export async function admin(argv) {
|
|
|
118
127
|
app.on("exit", () => clearInterval(timer));
|
|
119
128
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
120
129
|
url: target.url, report, snapshot, error, typing, restreaming,
|
|
130
|
+
links: target.links, key: target.key, source: target.source,
|
|
121
131
|
}));
|
|
122
132
|
await app.start();
|
|
123
133
|
clearInterval(timer);
|
|
@@ -159,6 +169,22 @@ export function draw(ui, theme, view) {
|
|
|
159
169
|
]);
|
|
160
170
|
});
|
|
161
171
|
});
|
|
172
|
+
// The links, because the point of a daemon is the phone in the other room
|
|
173
|
+
// and the address this process happens to talk to is the one that will not
|
|
174
|
+
// reach it. The key is on them: without it every address is a 401.
|
|
175
|
+
if (view.links.length > 0) {
|
|
176
|
+
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
177
|
+
ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
|
|
178
|
+
for (const link of view.links) {
|
|
179
|
+
const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
|
|
180
|
+
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
181
|
+
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (view.source)
|
|
185
|
+
p.label(view.source);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
162
188
|
ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
|
|
163
189
|
if (view.error) {
|
|
164
190
|
p.text(view.error, { fg: theme.danger });
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying no to somebody who is asking too often.
|
|
3
|
+
*
|
|
4
|
+
* There was nothing here at all: no throttle, no lockout, no 429. Against the
|
|
5
|
+
* share key that is survivable -- it is 128 random bits and nobody guesses one
|
|
6
|
+
* -- but the sign-in endpoints take a password, and a password with an eight
|
|
7
|
+
* character floor and no composition rules is guessable at a few hundred
|
|
8
|
+
* attempts a second by anyone with a word list.
|
|
9
|
+
*
|
|
10
|
+
* A fixed window rather than a token bucket, because the thing being counted
|
|
11
|
+
* is failed attempts by one caller and the useful question is "how many in the
|
|
12
|
+
* last few minutes", which a window answers exactly and a bucket only
|
|
13
|
+
* approximates. Windows are kept in memory: a limiter that outlives a restart
|
|
14
|
+
* would want a table, and a restart is not how somebody gets past this.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately free of anything nixamp: it takes a name and an address and
|
|
17
|
+
* answers yes or no, so it can be lifted into a package unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export interface Limit {
|
|
20
|
+
/** How many are allowed in one window. */
|
|
21
|
+
allowed: number;
|
|
22
|
+
/** How long the window is, in milliseconds. */
|
|
23
|
+
windowMs: number;
|
|
24
|
+
}
|
|
25
|
+
export interface Verdict {
|
|
26
|
+
/** Whether to go ahead. */
|
|
27
|
+
ok: boolean;
|
|
28
|
+
/** What is left in this window, after this call. */
|
|
29
|
+
left: number;
|
|
30
|
+
/** Seconds until the window rolls, for Retry-After. */
|
|
31
|
+
retryAfter: number;
|
|
32
|
+
}
|
|
33
|
+
export declare class Guard {
|
|
34
|
+
private readonly now;
|
|
35
|
+
private readonly windows;
|
|
36
|
+
constructor(now?: () => number);
|
|
37
|
+
/**
|
|
38
|
+
* Count one attempt against `key`, and say whether it may proceed.
|
|
39
|
+
*
|
|
40
|
+
* The count rises whether or not the attempt succeeds; callers who only want
|
|
41
|
+
* to punish failures should call `forget` on success, which is what makes a
|
|
42
|
+
* correct password cost nothing.
|
|
43
|
+
*/
|
|
44
|
+
check(key: string, limit: Limit): Verdict;
|
|
45
|
+
/** A success wipes the slate, so ordinary use never approaches a limit. */
|
|
46
|
+
forget(key: string): void;
|
|
47
|
+
private sweep;
|
|
48
|
+
get size(): number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Who is asking, for counting purposes.
|
|
52
|
+
*
|
|
53
|
+
* Behind a proxy the socket address is the proxy, so the forwarded header is
|
|
54
|
+
* used where one is trusted. It is only trusted when told to be: anyone can
|
|
55
|
+
* send `x-forwarded-for`, and believing it from a direct caller would let them
|
|
56
|
+
* pick a fresh identity per request and never hit a limit at all.
|
|
57
|
+
*/
|
|
58
|
+
export declare function callerOf(headers: Record<string, string | string[] | undefined>, socketAddress: string | undefined, behindProxy: boolean): string;
|
|
59
|
+
/**
|
|
60
|
+
* Signing in. Ten wrong answers in fifteen minutes is far more than a person
|
|
61
|
+
* mistypes and far less than a word list needs.
|
|
62
|
+
*/
|
|
63
|
+
export declare const SIGN_IN_LIMIT: Limit;
|
|
64
|
+
/**
|
|
65
|
+
* A wrong share key. Higher, because a browser with a stale link retries by
|
|
66
|
+
* itself, and the key is not guessable anyway -- this is about noise and logs
|
|
67
|
+
* rather than about the key falling.
|
|
68
|
+
*/
|
|
69
|
+
export declare const BAD_KEY_LIMIT: Limit;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying no to somebody who is asking too often.
|
|
3
|
+
*
|
|
4
|
+
* There was nothing here at all: no throttle, no lockout, no 429. Against the
|
|
5
|
+
* share key that is survivable -- it is 128 random bits and nobody guesses one
|
|
6
|
+
* -- but the sign-in endpoints take a password, and a password with an eight
|
|
7
|
+
* character floor and no composition rules is guessable at a few hundred
|
|
8
|
+
* attempts a second by anyone with a word list.
|
|
9
|
+
*
|
|
10
|
+
* A fixed window rather than a token bucket, because the thing being counted
|
|
11
|
+
* is failed attempts by one caller and the useful question is "how many in the
|
|
12
|
+
* last few minutes", which a window answers exactly and a bucket only
|
|
13
|
+
* approximates. Windows are kept in memory: a limiter that outlives a restart
|
|
14
|
+
* would want a table, and a restart is not how somebody gets past this.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately free of anything nixamp: it takes a name and an address and
|
|
17
|
+
* answers yes or no, so it can be lifted into a package unchanged.
|
|
18
|
+
*/
|
|
19
|
+
/** Nothing here is worth more memory than this; the oldest windows go first. */
|
|
20
|
+
const MAX_TRACKED = 10_000;
|
|
21
|
+
export class Guard {
|
|
22
|
+
now;
|
|
23
|
+
windows = new Map();
|
|
24
|
+
constructor(now = Date.now) {
|
|
25
|
+
this.now = now;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Count one attempt against `key`, and say whether it may proceed.
|
|
29
|
+
*
|
|
30
|
+
* The count rises whether or not the attempt succeeds; callers who only want
|
|
31
|
+
* to punish failures should call `forget` on success, which is what makes a
|
|
32
|
+
* correct password cost nothing.
|
|
33
|
+
*/
|
|
34
|
+
check(key, limit) {
|
|
35
|
+
const at = this.now();
|
|
36
|
+
this.sweep(at);
|
|
37
|
+
const found = this.windows.get(key);
|
|
38
|
+
const window = found && found.until > at ? found : { count: 0, until: at + limit.windowMs };
|
|
39
|
+
window.count += 1;
|
|
40
|
+
this.windows.set(key, window);
|
|
41
|
+
const retryAfter = Math.max(1, Math.ceil((window.until - at) / 1000));
|
|
42
|
+
if (window.count > limit.allowed)
|
|
43
|
+
return { ok: false, left: 0, retryAfter };
|
|
44
|
+
return { ok: true, left: limit.allowed - window.count, retryAfter };
|
|
45
|
+
}
|
|
46
|
+
/** A success wipes the slate, so ordinary use never approaches a limit. */
|
|
47
|
+
forget(key) {
|
|
48
|
+
this.windows.delete(key);
|
|
49
|
+
}
|
|
50
|
+
sweep(at) {
|
|
51
|
+
if (this.windows.size < MAX_TRACKED)
|
|
52
|
+
return;
|
|
53
|
+
for (const [key, window] of this.windows) {
|
|
54
|
+
if (window.until <= at)
|
|
55
|
+
this.windows.delete(key);
|
|
56
|
+
}
|
|
57
|
+
// Still full of live windows: somebody is spreading attempts across many
|
|
58
|
+
// keys. Drop the oldest half rather than grow without limit -- forgetting
|
|
59
|
+
// is the safe direction, since the alternative is running out of memory.
|
|
60
|
+
if (this.windows.size >= MAX_TRACKED) {
|
|
61
|
+
const half = Math.floor(this.windows.size / 2);
|
|
62
|
+
let dropped = 0;
|
|
63
|
+
for (const key of this.windows.keys()) {
|
|
64
|
+
this.windows.delete(key);
|
|
65
|
+
if (++dropped >= half)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.windows.size;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Who is asking, for counting purposes.
|
|
76
|
+
*
|
|
77
|
+
* Behind a proxy the socket address is the proxy, so the forwarded header is
|
|
78
|
+
* used where one is trusted. It is only trusted when told to be: anyone can
|
|
79
|
+
* send `x-forwarded-for`, and believing it from a direct caller would let them
|
|
80
|
+
* pick a fresh identity per request and never hit a limit at all.
|
|
81
|
+
*/
|
|
82
|
+
export function callerOf(headers, socketAddress, behindProxy) {
|
|
83
|
+
if (behindProxy) {
|
|
84
|
+
const raw = headers["x-forwarded-for"];
|
|
85
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
86
|
+
// The leftmost entry is the original client; the rest are proxies.
|
|
87
|
+
const first = header?.split(",")[0]?.trim();
|
|
88
|
+
if (first)
|
|
89
|
+
return first;
|
|
90
|
+
}
|
|
91
|
+
return socketAddress ?? "unknown";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Signing in. Ten wrong answers in fifteen minutes is far more than a person
|
|
95
|
+
* mistypes and far less than a word list needs.
|
|
96
|
+
*/
|
|
97
|
+
export const SIGN_IN_LIMIT = { allowed: 10, windowMs: 15 * 60_000 };
|
|
98
|
+
/**
|
|
99
|
+
* A wrong share key. Higher, because a browser with a stale link retries by
|
|
100
|
+
* itself, and the key is not guessable anyway -- this is about noise and logs
|
|
101
|
+
* rather than about the key falling.
|
|
102
|
+
*/
|
|
103
|
+
export const BAD_KEY_LIMIT = { allowed: 60, windowMs: 60_000 };
|
package/dist/server.d.ts
CHANGED
|
@@ -235,6 +235,13 @@ export interface HandlerOptions {
|
|
|
235
235
|
signIn?: SignIn;
|
|
236
236
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
237
237
|
secureCookies?: boolean;
|
|
238
|
+
/**
|
|
239
|
+
* True when a proxy sits in front, so `x-forwarded-for` names the caller.
|
|
240
|
+
* False everywhere else on purpose: the header is trivially forged, and
|
|
241
|
+
* believing it from a direct caller hands them a fresh identity per request
|
|
242
|
+
* and with it an unlimited number of password attempts.
|
|
243
|
+
*/
|
|
244
|
+
behindProxy?: boolean;
|
|
238
245
|
/** Who may administer this server. */
|
|
239
246
|
owner?: Owner;
|
|
240
247
|
/**
|
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { Channels, cleanId } from "./channels.js";
|
|
|
21
21
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
22
22
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
23
23
|
import { DeviceGrants } from "./device.js";
|
|
24
|
+
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
24
25
|
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
25
26
|
import { needsAdmin, Owner } from "./owner.js";
|
|
26
27
|
import { readSession } from "./session.js";
|
|
@@ -546,6 +547,8 @@ async function readBody(request, limit = 64 * 1024) {
|
|
|
546
547
|
* drive it with a real socket and no ffmpeg in sight.
|
|
547
548
|
*/
|
|
548
549
|
export function createHandler(engine, options) {
|
|
550
|
+
// One per server, so the counters survive between requests and die with it.
|
|
551
|
+
const guard = new Guard();
|
|
549
552
|
const tracker = options.connections ?? new Connections();
|
|
550
553
|
const started = Date.now();
|
|
551
554
|
/** Count a request in, count its bytes, and close it out exactly once. */
|
|
@@ -566,6 +569,7 @@ export function createHandler(engine, options) {
|
|
|
566
569
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
567
570
|
const path = url.pathname;
|
|
568
571
|
const key = options.key ?? null;
|
|
572
|
+
const behindProxy = options.behindProxy ?? false;
|
|
569
573
|
const listenKey = options.listenKey ?? null;
|
|
570
574
|
if (request.method === "OPTIONS") {
|
|
571
575
|
response.writeHead(204, CORS);
|
|
@@ -791,6 +795,15 @@ export function createHandler(engine, options) {
|
|
|
791
795
|
!isSignInPath(path)) {
|
|
792
796
|
const scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
793
797
|
if (scope === null) {
|
|
798
|
+
// Counted, not because a 128-bit key falls to guessing, but because
|
|
799
|
+
// somebody hammering one should stop costing this server anything.
|
|
800
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
801
|
+
const verdict = guard.check(`key:${who}`, BAD_KEY_LIMIT);
|
|
802
|
+
if (!verdict.ok) {
|
|
803
|
+
response.writeHead(429, { ...CORS, "content-type": "application/json; charset=utf-8", "retry-after": String(verdict.retryAfter) });
|
|
804
|
+
response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
794
807
|
json(response, 401, { error: "this nixamp needs the key from its share link" });
|
|
795
808
|
return;
|
|
796
809
|
}
|
|
@@ -993,9 +1006,33 @@ export function createHandler(engine, options) {
|
|
|
993
1006
|
json(response, 400, { error: "bad JSON" });
|
|
994
1007
|
return;
|
|
995
1008
|
}
|
|
1009
|
+
// A password is the one secret here small enough to guess, so the
|
|
1010
|
+
// attempts are counted per caller and per address being tried: one
|
|
1011
|
+
// machine working through a word list and a thousand machines trying one
|
|
1012
|
+
// address are the same attack, and each is stopped by its own counter.
|
|
1013
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
1014
|
+
const target = typeof body.email === "string" ? body.email.toLowerCase().slice(0, 200) : "";
|
|
1015
|
+
const buckets = [`signin:${who}`, `signin:${target}`];
|
|
1016
|
+
for (const bucket of buckets) {
|
|
1017
|
+
const verdict = guard.check(bucket, SIGN_IN_LIMIT);
|
|
1018
|
+
if (!verdict.ok) {
|
|
1019
|
+
response.writeHead(429, {
|
|
1020
|
+
...CORS,
|
|
1021
|
+
"content-type": "application/json; charset=utf-8",
|
|
1022
|
+
"retry-after": String(verdict.retryAfter),
|
|
1023
|
+
});
|
|
1024
|
+
response.end(JSON.stringify({ error: "too many attempts; try again later" }));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
996
1028
|
const result = signingUp
|
|
997
1029
|
? await accounts.signUp(body.email, body.password)
|
|
998
1030
|
: await accounts.signIn(body.email, body.password);
|
|
1031
|
+
// Getting it right costs nothing: the counters only exist to stop people
|
|
1032
|
+
// who keep getting it wrong.
|
|
1033
|
+
if (result.ok)
|
|
1034
|
+
for (const bucket of buckets)
|
|
1035
|
+
guard.forget(bucket);
|
|
999
1036
|
if (!result.ok) {
|
|
1000
1037
|
// 409 for an address that is taken, 401 for credentials that are not.
|
|
1001
1038
|
json(response, signingUp ? 409 : 401, { error: result.error });
|
|
@@ -2060,6 +2097,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2060
2097
|
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
2061
2098
|
}),
|
|
2062
2099
|
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
2100
|
+
// A deployment reached over https is one behind somebody's proxy, so
|
|
2101
|
+
// the socket address is that proxy and the forwarded header is the
|
|
2102
|
+
// caller. A nixamp on a laptop is reached directly and must not
|
|
2103
|
+
// believe a header anybody can send.
|
|
2104
|
+
behindProxy: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
2063
2105
|
// Whichever providers this deployment was given both halves of, plus
|
|
2064
2106
|
// the device grant, which is worth having even with no provider at
|
|
2065
2107
|
// all: a browser already signed in can approve a terminal.
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -30,6 +30,16 @@ interface Snapshot {
|
|
|
30
30
|
export interface AdminOptions {
|
|
31
31
|
url: string;
|
|
32
32
|
key: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Every address the server found, labelled, share key not yet applied.
|
|
35
|
+
*
|
|
36
|
+
* `url` is the one this process talks to, which for a local daemon is
|
|
37
|
+
* loopback -- correct for asking it questions and useless for handing to
|
|
38
|
+
* anybody. These are the ones worth reading off the screen.
|
|
39
|
+
*/
|
|
40
|
+
links: { label: string; url: string }[];
|
|
41
|
+
/** What it is serving, so the admin view says so without being asked. */
|
|
42
|
+
source: string;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/** Where to point, from the flags or from the daemon that is running. */
|
|
@@ -39,13 +49,23 @@ export function resolveTarget(argv: string[]): AdminOptions {
|
|
|
39
49
|
const url = at === -1 ? null : argv[at + 1];
|
|
40
50
|
const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
// Pointed somewhere by hand, that address is the only one known.
|
|
53
|
+
if (url) {
|
|
54
|
+
const bare = url.replace(/\/+$/, "");
|
|
55
|
+
return { url: bare, key, links: [{ label: "there", url: bare }], source: "" };
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
const state = readState();
|
|
45
59
|
if (state === null) {
|
|
46
60
|
throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
|
|
47
61
|
}
|
|
48
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
url: daemonUrl(state),
|
|
64
|
+
key: key ?? state.key,
|
|
65
|
+
// A state file written before 0.5.3 has no list; loopback stands in.
|
|
66
|
+
links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
|
|
67
|
+
source: state.source,
|
|
68
|
+
};
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
/** Seconds as something a person reads at a glance. */
|
|
@@ -134,6 +154,7 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
134
154
|
app.on("exit", () => clearInterval(timer));
|
|
135
155
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
136
156
|
url: target.url, report, snapshot, error, typing, restreaming,
|
|
157
|
+
links: target.links, key: target.key, source: target.source,
|
|
137
158
|
}));
|
|
138
159
|
|
|
139
160
|
await app.start();
|
|
@@ -161,6 +182,10 @@ export interface View {
|
|
|
161
182
|
error: string;
|
|
162
183
|
typing: boolean;
|
|
163
184
|
restreaming: string;
|
|
185
|
+
/** Labelled addresses, and the key that makes them work. */
|
|
186
|
+
links: { label: string; url: string }[];
|
|
187
|
+
key: string | null;
|
|
188
|
+
source: string;
|
|
164
189
|
}
|
|
165
190
|
|
|
166
191
|
export function draw(ui: Container, theme: Theme, view: View): void {
|
|
@@ -189,6 +214,22 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
189
214
|
});
|
|
190
215
|
});
|
|
191
216
|
|
|
217
|
+
// The links, because the point of a daemon is the phone in the other room
|
|
218
|
+
// and the address this process happens to talk to is the one that will not
|
|
219
|
+
// reach it. The key is on them: without it every address is a 401.
|
|
220
|
+
if (view.links.length > 0) {
|
|
221
|
+
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
222
|
+
ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
|
|
223
|
+
for (const link of view.links) {
|
|
224
|
+
const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
|
|
225
|
+
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
226
|
+
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (view.source) p.label(view.source);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
192
233
|
ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
|
|
193
234
|
if (view.error) {
|
|
194
235
|
p.text(view.error, { fg: theme.danger });
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying no to somebody who is asking too often.
|
|
3
|
+
*
|
|
4
|
+
* There was nothing here at all: no throttle, no lockout, no 429. Against the
|
|
5
|
+
* share key that is survivable -- it is 128 random bits and nobody guesses one
|
|
6
|
+
* -- but the sign-in endpoints take a password, and a password with an eight
|
|
7
|
+
* character floor and no composition rules is guessable at a few hundred
|
|
8
|
+
* attempts a second by anyone with a word list.
|
|
9
|
+
*
|
|
10
|
+
* A fixed window rather than a token bucket, because the thing being counted
|
|
11
|
+
* is failed attempts by one caller and the useful question is "how many in the
|
|
12
|
+
* last few minutes", which a window answers exactly and a bucket only
|
|
13
|
+
* approximates. Windows are kept in memory: a limiter that outlives a restart
|
|
14
|
+
* would want a table, and a restart is not how somebody gets past this.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately free of anything nixamp: it takes a name and an address and
|
|
17
|
+
* answers yes or no, so it can be lifted into a package unchanged.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface Limit {
|
|
21
|
+
/** How many are allowed in one window. */
|
|
22
|
+
allowed: number;
|
|
23
|
+
/** How long the window is, in milliseconds. */
|
|
24
|
+
windowMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Verdict {
|
|
28
|
+
/** Whether to go ahead. */
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** What is left in this window, after this call. */
|
|
31
|
+
left: number;
|
|
32
|
+
/** Seconds until the window rolls, for Retry-After. */
|
|
33
|
+
retryAfter: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Window {
|
|
37
|
+
count: number;
|
|
38
|
+
until: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Nothing here is worth more memory than this; the oldest windows go first. */
|
|
42
|
+
const MAX_TRACKED = 10_000;
|
|
43
|
+
|
|
44
|
+
export class Guard {
|
|
45
|
+
private readonly windows = new Map<string, Window>();
|
|
46
|
+
|
|
47
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Count one attempt against `key`, and say whether it may proceed.
|
|
51
|
+
*
|
|
52
|
+
* The count rises whether or not the attempt succeeds; callers who only want
|
|
53
|
+
* to punish failures should call `forget` on success, which is what makes a
|
|
54
|
+
* correct password cost nothing.
|
|
55
|
+
*/
|
|
56
|
+
check(key: string, limit: Limit): Verdict {
|
|
57
|
+
const at = this.now();
|
|
58
|
+
this.sweep(at);
|
|
59
|
+
|
|
60
|
+
const found = this.windows.get(key);
|
|
61
|
+
const window = found && found.until > at ? found : { count: 0, until: at + limit.windowMs };
|
|
62
|
+
window.count += 1;
|
|
63
|
+
this.windows.set(key, window);
|
|
64
|
+
|
|
65
|
+
const retryAfter = Math.max(1, Math.ceil((window.until - at) / 1000));
|
|
66
|
+
if (window.count > limit.allowed) return { ok: false, left: 0, retryAfter };
|
|
67
|
+
return { ok: true, left: limit.allowed - window.count, retryAfter };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A success wipes the slate, so ordinary use never approaches a limit. */
|
|
71
|
+
forget(key: string): void {
|
|
72
|
+
this.windows.delete(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private sweep(at: number): void {
|
|
76
|
+
if (this.windows.size < MAX_TRACKED) return;
|
|
77
|
+
for (const [key, window] of this.windows) {
|
|
78
|
+
if (window.until <= at) this.windows.delete(key);
|
|
79
|
+
}
|
|
80
|
+
// Still full of live windows: somebody is spreading attempts across many
|
|
81
|
+
// keys. Drop the oldest half rather than grow without limit -- forgetting
|
|
82
|
+
// is the safe direction, since the alternative is running out of memory.
|
|
83
|
+
if (this.windows.size >= MAX_TRACKED) {
|
|
84
|
+
const half = Math.floor(this.windows.size / 2);
|
|
85
|
+
let dropped = 0;
|
|
86
|
+
for (const key of this.windows.keys()) {
|
|
87
|
+
this.windows.delete(key);
|
|
88
|
+
if (++dropped >= half) break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get size(): number {
|
|
94
|
+
return this.windows.size;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Who is asking, for counting purposes.
|
|
100
|
+
*
|
|
101
|
+
* Behind a proxy the socket address is the proxy, so the forwarded header is
|
|
102
|
+
* used where one is trusted. It is only trusted when told to be: anyone can
|
|
103
|
+
* send `x-forwarded-for`, and believing it from a direct caller would let them
|
|
104
|
+
* pick a fresh identity per request and never hit a limit at all.
|
|
105
|
+
*/
|
|
106
|
+
export function callerOf(
|
|
107
|
+
headers: Record<string, string | string[] | undefined>,
|
|
108
|
+
socketAddress: string | undefined,
|
|
109
|
+
behindProxy: boolean,
|
|
110
|
+
): string {
|
|
111
|
+
if (behindProxy) {
|
|
112
|
+
const raw = headers["x-forwarded-for"];
|
|
113
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
114
|
+
// The leftmost entry is the original client; the rest are proxies.
|
|
115
|
+
const first = header?.split(",")[0]?.trim();
|
|
116
|
+
if (first) return first;
|
|
117
|
+
}
|
|
118
|
+
return socketAddress ?? "unknown";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Signing in. Ten wrong answers in fifteen minutes is far more than a person
|
|
123
|
+
* mistypes and far less than a word list needs.
|
|
124
|
+
*/
|
|
125
|
+
export const SIGN_IN_LIMIT: Limit = { allowed: 10, windowMs: 15 * 60_000 };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A wrong share key. Higher, because a browser with a stale link retries by
|
|
129
|
+
* itself, and the key is not guessable anyway -- this is about noise and logs
|
|
130
|
+
* rather than about the key falling.
|
|
131
|
+
*/
|
|
132
|
+
export const BAD_KEY_LIMIT: Limit = { allowed: 60, windowMs: 60_000 };
|
package/src/server.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { Channels, cleanId } from "./channels.ts";
|
|
|
28
28
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
29
29
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
30
30
|
import { DeviceGrants } from "./device.ts";
|
|
31
|
+
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
|
|
31
32
|
import {
|
|
32
33
|
deviceDonePage,
|
|
33
34
|
devicePage,
|
|
@@ -708,6 +709,13 @@ export interface HandlerOptions {
|
|
|
708
709
|
signIn?: SignIn;
|
|
709
710
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
710
711
|
secureCookies?: boolean;
|
|
712
|
+
/**
|
|
713
|
+
* True when a proxy sits in front, so `x-forwarded-for` names the caller.
|
|
714
|
+
* False everywhere else on purpose: the header is trivially forged, and
|
|
715
|
+
* believing it from a direct caller hands them a fresh identity per request
|
|
716
|
+
* and with it an unlimited number of password attempts.
|
|
717
|
+
*/
|
|
718
|
+
behindProxy?: boolean;
|
|
711
719
|
/** Who may administer this server. */
|
|
712
720
|
owner?: Owner;
|
|
713
721
|
/**
|
|
@@ -730,6 +738,9 @@ export interface HandlerOptions {
|
|
|
730
738
|
* drive it with a real socket and no ffmpeg in sight.
|
|
731
739
|
*/
|
|
732
740
|
export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
741
|
+
// One per server, so the counters survive between requests and die with it.
|
|
742
|
+
const guard = new Guard();
|
|
743
|
+
|
|
733
744
|
const tracker = options.connections ?? new Connections();
|
|
734
745
|
const started = Date.now();
|
|
735
746
|
|
|
@@ -757,6 +768,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
757
768
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
758
769
|
const path = url.pathname;
|
|
759
770
|
const key = options.key ?? null;
|
|
771
|
+
const behindProxy = options.behindProxy ?? false;
|
|
760
772
|
const listenKey = options.listenKey ?? null;
|
|
761
773
|
|
|
762
774
|
if (request.method === "OPTIONS") {
|
|
@@ -1004,6 +1016,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1004
1016
|
) {
|
|
1005
1017
|
const scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
1006
1018
|
if (scope === null) {
|
|
1019
|
+
// Counted, not because a 128-bit key falls to guessing, but because
|
|
1020
|
+
// somebody hammering one should stop costing this server anything.
|
|
1021
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
1022
|
+
const verdict = guard.check(`key:${who}`, BAD_KEY_LIMIT);
|
|
1023
|
+
if (!verdict.ok) {
|
|
1024
|
+
response.writeHead(429, { ...CORS, "content-type": "application/json; charset=utf-8", "retry-after": String(verdict.retryAfter) });
|
|
1025
|
+
response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1007
1028
|
json(response, 401, { error: "this nixamp needs the key from its share link" });
|
|
1008
1029
|
return;
|
|
1009
1030
|
}
|
|
@@ -1224,10 +1245,34 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1224
1245
|
return;
|
|
1225
1246
|
}
|
|
1226
1247
|
|
|
1248
|
+
// A password is the one secret here small enough to guess, so the
|
|
1249
|
+
// attempts are counted per caller and per address being tried: one
|
|
1250
|
+
// machine working through a word list and a thousand machines trying one
|
|
1251
|
+
// address are the same attack, and each is stopped by its own counter.
|
|
1252
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
1253
|
+
const target = typeof body.email === "string" ? body.email.toLowerCase().slice(0, 200) : "";
|
|
1254
|
+
const buckets = [`signin:${who}`, `signin:${target}`];
|
|
1255
|
+
for (const bucket of buckets) {
|
|
1256
|
+
const verdict = guard.check(bucket, SIGN_IN_LIMIT);
|
|
1257
|
+
if (!verdict.ok) {
|
|
1258
|
+
response.writeHead(429, {
|
|
1259
|
+
...CORS,
|
|
1260
|
+
"content-type": "application/json; charset=utf-8",
|
|
1261
|
+
"retry-after": String(verdict.retryAfter),
|
|
1262
|
+
});
|
|
1263
|
+
response.end(JSON.stringify({ error: "too many attempts; try again later" }));
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1227
1268
|
const result = signingUp
|
|
1228
1269
|
? await accounts.signUp(body.email, body.password)
|
|
1229
1270
|
: await accounts.signIn(body.email, body.password);
|
|
1230
1271
|
|
|
1272
|
+
// Getting it right costs nothing: the counters only exist to stop people
|
|
1273
|
+
// who keep getting it wrong.
|
|
1274
|
+
if (result.ok) for (const bucket of buckets) guard.forget(bucket);
|
|
1275
|
+
|
|
1231
1276
|
if (!result.ok) {
|
|
1232
1277
|
// 409 for an address that is taken, 401 for credentials that are not.
|
|
1233
1278
|
json(response, signingUp ? 409 : 401, { error: result.error });
|
|
@@ -2360,6 +2405,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2360
2405
|
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
2361
2406
|
}),
|
|
2362
2407
|
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
2408
|
+
// A deployment reached over https is one behind somebody's proxy, so
|
|
2409
|
+
// the socket address is that proxy and the forwarded header is the
|
|
2410
|
+
// caller. A nixamp on a laptop is reached directly and must not
|
|
2411
|
+
// believe a header anybody can send.
|
|
2412
|
+
behindProxy: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
2363
2413
|
// Whichever providers this deployment was given both halves of, plus
|
|
2364
2414
|
// the device grant, which is worth having even with no provider at
|
|
2365
2415
|
// all: a browser already signed in can approve a terminal.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(m(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=f,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function m(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),tracks:t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0)}}),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ee=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(b(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(_(this.base,`/api/command`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function b(e){try{return JSON.parse(e)}catch{return null}}async function te(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function x(e,t){try{let n=await fetch(_(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}var S=.14,C=.02;function ne(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function re(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function ie(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function w(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ae(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var oe=`nixamp.remote`,se=`nixamp.volume`;function T(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function E(){let n={status:T(`status`),source:T(`source`),install:T(`install`),video:T(`video`),audio:T(`audio`),title:T(`title-line`),album:T(`album-line`),elapsed:T(`elapsed`),total:T(`total`),seek:T(`seek`),canvas:T(`spectrum`),glyphs:T(`glyphs`),levels:T(`levels`),playlist:T(`playlist`),playlistTitle:T(`playlist-panel`),note:T(`note`),files:T(`files`),folder:T(`folder`),remoteUrl:T(`remote-url`),remoteForm:T(`remote-form`),remoteState:T(`remote-state`),disconnect:T(`disconnect`),browse:T(`browse`),accountForm:T(`account-form`),accountEmail:T(`account-email`),accountPassword:T(`account-password`),accountSubmit:T(`account-submit`),accountToggle:T(`account-toggle`),accountProviders:T(`account-providers`),accountPanel:T(`account-panel`),accountElsewhere:T(`account-elsewhere`),accountSignOut:T(`account-signout`),accountNote:T(`account-note`),adminPanel:T(`admin-panel`),adminNote:T(`admin-note`),adminConnections:T(`admin-connections`),adminRestream:T(`admin-restream`),adminSource:T(`admin-source`),directory:T(`directory`),recentNote:T(`recent-note`),recentList:T(`recent-list`),followingNote:T(`following-note`),followingList:T(`following-list`),notifyPanel:T(`notify-panel`),notifyNote:T(`notify-note`),notifyWeb:T(`notify-web`),notifyEmail:T(`notify-email`),notifySms:T(`notify-sms`),notifyPhone:T(`notify-phone`),notifyPhoneForm:T(`notify-phone-form`),notifyPhoneNote:T(`notify-phone-note`),directoryNote:T(`directory-note`),directoryList:T(`directory-list`),listenHere:T(`listen-here`),volume:T(`volume`),prev:T(`prev`),playPause:T(`play-pause`),stop:T(`stop`),next:T(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,b=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>F(1),onState:()=>I(),onError:e=>{u=e,I()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=w(_,m)),I()},onStatus:(e,t)=>{s=e,l=t??``,I()}}),C=()=>r===`remote`?o.tracks.length:i.length,E=()=>r===`remote`?o.index:a,D=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},O=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,k=()=>y()?o.tracks[o.index]?.duration??0:b.duration,A=()=>y()?o.position:b.position,j=()=>y()?o.playing:b.playing;async function M(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await N(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),de(t.video),z(),I())}async function N(e){let t=o.tracks[e];t&&(await b.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:S.media(e),video:!1,objectUrl:!1},!0),z())}async function P(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await M(E()),I())}async function F(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await M((E()+e+t)%t)}}async function ce(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],I()}let le=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=C(),a=j();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=D(),n.album.textContent=O();let c=A(),d=k();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${S.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,ue(),n.glyphs.textContent=m.map(le).join(``);let[h,g]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let L=``;function ue(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==L&&(L=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=E(),l=j();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function R(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(y())_=w(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=ie(m,re(e,v)),_=w(_,m))}if(s){let e=getComputedStyle(document.documentElement);ae(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(j()){n.glyphs.textContent=m.map(le).join(``);let[t,r]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(A());let i=k();!f&&i>0&&(n.seek.value=String(Math.round(A()/i*1e3)))}requestAnimationFrame(R)}function de(e){n.video.hidden=!e}function z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:D(),album:O(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void P()),navigator.mediaSession.setActionHandler(`pause`,()=>void P()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&M(n)}),n.prev.addEventListener(`click`,()=>void F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void ce()),n.playPause.addEventListener(`click`,()=>void P()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=k();e>0&&b.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;b.volume=e;try{localStorage.setItem(se,String(e))}catch{}});let B=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,I();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,M(0)})};B(n.files),B(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,I();return}(async()=>{if(s=`connecting`,I(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,I();return}r=`remote`,u=``;try{localStorage.setItem(oe,t)}catch{}S.connect(t),I()})()});let V=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],pe(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&Z&&t.ownerId!==Z&&e.append(q(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),V()}let H=null,fe=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},U=async()=>{try{let e=await fetch(`/api/connections`);if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,fe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},W=async()=>{let e=!1,t=null;try{let n=await fetch(`/api/admin`);if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,H&&clearInterval(H),H=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,U(),H=setInterval(()=>void U(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&(async()=>{try{let e=await fetch(`/api/source`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t})}),r=await e.json();n.adminNote.textContent=e.ok?`Now serving ${t}.`:r.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let G=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},pe=e=>{n.recentList.replaceChildren();let t=Z?e.filter(e=>e.ownerId&&e.ownerId!==Z):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${G(e.endedAt)}`:`ended ${G(e.endedAt)}`,r.append(i,a),t.append(r,q(e.ownerId,e.name)),n.recentList.append(t)}},K=async()=>{n.followingList.replaceChildren();try{let e=await fetch(`/api/v1/follows`);if(!e.ok){n.followingNote.hidden=!0;return}let t=(await e.json()).following??[];n.followingNote.hidden=t.length===0;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||`a nixamp`;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.live?`live now`:`not streaming`,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`ghost follow`,o.textContent=`Unfollow`,o.addEventListener(`click`,()=>{(async()=>{o.disabled=!0;try{await fetch(`/api/v1/follows/${encodeURIComponent(e.id)}`,{method:`DELETE`}),t.remove(),n.followingList.children.length===0&&(n.followingNote.hidden=!0)}finally{o.disabled=!1}})()}),t.append(r,o),n.followingList.append(t)}}catch{n.followingNote.hidden=!0}},q=(e,t)=>{let n=document.createElement(`button`);n.type=`button`,n.className=`ghost follow`,n.textContent=`Follow`,n.setAttribute(`aria-label`,`Follow ${t}`);let r=e=>{n.textContent=e?`Following`:`Follow`,n.dataset.following=e?`yes`:`no`};return(async()=>{try{let t=await fetch(`/api/v1/follows/${encodeURIComponent(e)}`);t.ok&&r((await t.json()).following===!0)}catch{}})(),n.addEventListener(`click`,()=>{(async()=>{let t=n.dataset.following===`yes`;n.disabled=!0;try{(await fetch(`/api/v1/follows/${encodeURIComponent(e)}`,{method:t?`DELETE`:`PUT`,headers:{"content-type":`application/json`},body:t?void 0:`{}`})).ok&&(r(!t),K())}catch{}finally{n.disabled=!1}})()}),n},me=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},J=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,he=async()=>{if(!J())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:me(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},ge=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},Y=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},_e=async()=>{try{let e=await fetch(`/api/v1/notify/prefs`);if(!e.ok)return;let t=await e.json();n.notifyEmail.checked=t.wantsEmail!==!1,n.notifySms.checked=t.wantsSms===!0,n.notifyPhone.value=t.phone??``;let r=J()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await he();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ge(),await Y({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Y({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await Y({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Y({phone:n.notifyPhone.value.trim()})});let X=!1,Z=``,Q=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(_e(),K()):(n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:X?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=X?`Create account`:`Sign in`,n.accountToggle.textContent=X?`I have one`:`Create one`,n.accountPassword.autocomplete=X?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{X=!X,Q(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${X?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}Z=i.account?.id??``,n.accountPassword.value=``,Q(i.account?.email??t),W()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}Z=``,Q(null),W()})()}),(async()=>{let e=[],t=!1;try{let n=await fetch(`/api/v1/auth/providers`);n.ok&&(t=!0,e=(await n.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0,n.accountPanel.hidden=!t,n.accountElsewhere.hidden=t;for(let t of e){let e=document.createElement(`a`);e.className=`button`,e.href=`/api/v1/${encodeURIComponent(t.id)}/oauth/start`,e.textContent=`Continue with ${t.name}`,n.accountProviders.append(e)}})(),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Z=e.ok?t.account?.id??``:``,Q(e.ok?t.account?.email??`you`:null)}catch{Z=``,Q(null)}})(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,I()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await N(o.index)):b.stop(),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),P();return;case`s`:ce();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),M(Math.min(C()-1,E()+1));return;case`ArrowUp`:e.preventDefault(),M(Math.max(0,E()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(se);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(oe);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await x(e)===null)return;let t=await te(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,S.connect(e),I())})(),I(),requestAnimationFrame(R)}E(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
|
package/web/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<meta property="og:title" content="nixamp" />
|
|
17
17
|
<meta property="og:description" content="It really whips the terminal's ass." />
|
|
18
18
|
<meta property="og:type" content="website" />
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BD37tdcP.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-DSIDSSPF.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
<p class="hint"><a href="/" id="directory-back" hidden>Back to the player</a></p>
|
|
89
89
|
</section>
|
|
90
90
|
|
|
91
|
-
<section class="panel player-only" data-title="Account">
|
|
91
|
+
<section class="panel player-only" data-title="Account" id="account-panel">
|
|
92
92
|
<p class="hint" id="account-note">Sign in to nixamp.com to publish and get paid.</p>
|
|
93
93
|
<div id="account-providers" class="picker" hidden></div>
|
|
94
94
|
<form id="account-form" class="picker">
|
|
@@ -102,6 +102,11 @@
|
|
|
102
102
|
</form>
|
|
103
103
|
</section>
|
|
104
104
|
|
|
105
|
+
<p class="hint player-only" id="account-elsewhere" hidden>
|
|
106
|
+
This nixamp keeps no accounts — it is somebody's own machine.
|
|
107
|
+
Accounts live at <a href="https://nixamp.com">nixamp.com</a>.
|
|
108
|
+
</p>
|
|
109
|
+
|
|
105
110
|
<section class="panel player-only" data-title="Notifications" id="notify-panel" hidden>
|
|
106
111
|
<p class="hint" id="notify-note">Get told when someone you follow goes live.</p>
|
|
107
112
|
<div class="picker">
|
|
@@ -146,7 +151,7 @@
|
|
|
146
151
|
|
|
147
152
|
<label class="check">
|
|
148
153
|
<input id="listen-here" type="checkbox" />
|
|
149
|
-
|
|
154
|
+
Play on this device
|
|
150
155
|
</label>
|
|
151
156
|
<p class="hint">Status: <span id="remote-state" data-status="idle">not connected</span></p>
|
|
152
157
|
</section>
|
package/web/dist/sw.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/* nixamp service worker — generated, do not edit */
|
|
2
|
-
const CACHE = "nixamp-
|
|
2
|
+
const CACHE = "nixamp-1788940143847";
|
|
3
3
|
const PRECACHE = [
|
|
4
4
|
"/",
|
|
5
5
|
"/apple-touch-icon.png",
|
|
6
|
+
"/assets/index-BD37tdcP.js",
|
|
6
7
|
"/assets/index-DSIDSSPF.css",
|
|
7
|
-
"/assets/index-pztl5rKf.js",
|
|
8
8
|
"/icons/icon-192-maskable.png",
|
|
9
9
|
"/icons/icon-192.png",
|
|
10
10
|
"/icons/icon-512-maskable.png",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(m(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=f,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function m(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),tracks:t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0)}}),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ee=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(b(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(_(this.base,`/api/command`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function b(e){try{return JSON.parse(e)}catch{return null}}async function te(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function x(e,t){try{let n=await fetch(_(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}var S=.14,C=.02;function ne(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function re(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function ie(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function w(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ae(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var oe=`nixamp.remote`,se=`nixamp.volume`;function T(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function E(){let n={status:T(`status`),source:T(`source`),install:T(`install`),video:T(`video`),audio:T(`audio`),title:T(`title-line`),album:T(`album-line`),elapsed:T(`elapsed`),total:T(`total`),seek:T(`seek`),canvas:T(`spectrum`),glyphs:T(`glyphs`),levels:T(`levels`),playlist:T(`playlist`),playlistTitle:T(`playlist-panel`),note:T(`note`),files:T(`files`),folder:T(`folder`),remoteUrl:T(`remote-url`),remoteForm:T(`remote-form`),remoteState:T(`remote-state`),disconnect:T(`disconnect`),browse:T(`browse`),accountForm:T(`account-form`),accountEmail:T(`account-email`),accountPassword:T(`account-password`),accountSubmit:T(`account-submit`),accountToggle:T(`account-toggle`),accountProviders:T(`account-providers`),accountSignOut:T(`account-signout`),accountNote:T(`account-note`),adminPanel:T(`admin-panel`),adminNote:T(`admin-note`),adminConnections:T(`admin-connections`),adminRestream:T(`admin-restream`),adminSource:T(`admin-source`),directory:T(`directory`),recentNote:T(`recent-note`),recentList:T(`recent-list`),followingNote:T(`following-note`),followingList:T(`following-list`),notifyPanel:T(`notify-panel`),notifyNote:T(`notify-note`),notifyWeb:T(`notify-web`),notifyEmail:T(`notify-email`),notifySms:T(`notify-sms`),notifyPhone:T(`notify-phone`),notifyPhoneForm:T(`notify-phone-form`),notifyPhoneNote:T(`notify-phone-note`),directoryNote:T(`directory-note`),directoryList:T(`directory-list`),listenHere:T(`listen-here`),volume:T(`volume`),prev:T(`prev`),playPause:T(`play-pause`),stop:T(`stop`),next:T(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,b=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>F(1),onState:()=>I(),onError:e=>{u=e,I()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=w(_,m)),I()},onStatus:(e,t)=>{s=e,l=t??``,I()}}),C=()=>r===`remote`?o.tracks.length:i.length,E=()=>r===`remote`?o.index:a,D=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},O=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,k=()=>y()?o.tracks[o.index]?.duration??0:b.duration,A=()=>y()?o.position:b.position,j=()=>y()?o.playing:b.playing;async function M(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await N(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),de(t.video),z(),I())}async function N(e){let t=o.tracks[e];t&&(await b.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:S.media(e),video:!1,objectUrl:!1},!0),z())}async function P(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await M(E()),I())}async function F(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await M((E()+e+t)%t)}}async function ce(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],I()}let le=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=C(),a=j();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=D(),n.album.textContent=O();let c=A(),d=k();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${S.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,ue(),n.glyphs.textContent=m.map(le).join(``);let[h,g]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let L=``;function ue(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==L&&(L=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=E(),l=j();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function R(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(y())_=w(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=ie(m,re(e,v)),_=w(_,m))}if(s){let e=getComputedStyle(document.documentElement);ae(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(j()){n.glyphs.textContent=m.map(le).join(``);let[t,r]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(A());let i=k();!f&&i>0&&(n.seek.value=String(Math.round(A()/i*1e3)))}requestAnimationFrame(R)}function de(e){n.video.hidden=!e}function z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:D(),album:O(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void P()),navigator.mediaSession.setActionHandler(`pause`,()=>void P()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&M(n)}),n.prev.addEventListener(`click`,()=>void F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void ce()),n.playPause.addEventListener(`click`,()=>void P()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=k();e>0&&b.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;b.volume=e;try{localStorage.setItem(se,String(e))}catch{}});let B=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,I();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,M(0)})};B(n.files),B(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,I();return}(async()=>{if(s=`connecting`,I(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,I();return}r=`remote`,u=``;try{localStorage.setItem(oe,t)}catch{}S.connect(t),I()})()});let V=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],pe(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&Z&&t.ownerId!==Z&&e.append(q(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),V()}let H=null,fe=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},U=async()=>{try{let e=await fetch(`/api/connections`);if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,fe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},W=async()=>{let e=!1,t=null;try{let n=await fetch(`/api/admin`);if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,H&&clearInterval(H),H=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,U(),H=setInterval(()=>void U(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&(async()=>{try{let e=await fetch(`/api/source`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t})}),r=await e.json();n.adminNote.textContent=e.ok?`Now serving ${t}.`:r.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let G=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},pe=e=>{n.recentList.replaceChildren();let t=Z?e.filter(e=>e.ownerId&&e.ownerId!==Z):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${G(e.endedAt)}`:`ended ${G(e.endedAt)}`,r.append(i,a),t.append(r,q(e.ownerId,e.name)),n.recentList.append(t)}},K=async()=>{n.followingList.replaceChildren();try{let e=await fetch(`/api/v1/follows`);if(!e.ok){n.followingNote.hidden=!0;return}let t=(await e.json()).following??[];n.followingNote.hidden=t.length===0;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||`a nixamp`;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.live?`live now`:`not streaming`,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`ghost follow`,o.textContent=`Unfollow`,o.addEventListener(`click`,()=>{(async()=>{o.disabled=!0;try{await fetch(`/api/v1/follows/${encodeURIComponent(e.id)}`,{method:`DELETE`}),t.remove(),n.followingList.children.length===0&&(n.followingNote.hidden=!0)}finally{o.disabled=!1}})()}),t.append(r,o),n.followingList.append(t)}}catch{n.followingNote.hidden=!0}},q=(e,t)=>{let n=document.createElement(`button`);n.type=`button`,n.className=`ghost follow`,n.textContent=`Follow`,n.setAttribute(`aria-label`,`Follow ${t}`);let r=e=>{n.textContent=e?`Following`:`Follow`,n.dataset.following=e?`yes`:`no`};return(async()=>{try{let t=await fetch(`/api/v1/follows/${encodeURIComponent(e)}`);t.ok&&r((await t.json()).following===!0)}catch{}})(),n.addEventListener(`click`,()=>{(async()=>{let t=n.dataset.following===`yes`;n.disabled=!0;try{(await fetch(`/api/v1/follows/${encodeURIComponent(e)}`,{method:t?`DELETE`:`PUT`,headers:{"content-type":`application/json`},body:t?void 0:`{}`})).ok&&(r(!t),K())}catch{}finally{n.disabled=!1}})()}),n},me=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},J=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,he=async()=>{if(!J())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:me(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},ge=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},Y=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},_e=async()=>{try{let e=await fetch(`/api/v1/notify/prefs`);if(!e.ok)return;let t=await e.json();n.notifyEmail.checked=t.wantsEmail!==!1,n.notifySms.checked=t.wantsSms===!0,n.notifyPhone.value=t.phone??``;let r=J()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await he();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ge(),await Y({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Y({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await Y({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Y({phone:n.notifyPhone.value.trim()})});let X=!1,Z=``,Q=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(_e(),K()):(n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:X?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=X?`Create account`:`Sign in`,n.accountToggle.textContent=X?`I have one`:`Create one`,n.accountPassword.autocomplete=X?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{X=!X,Q(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${X?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}Z=i.account?.id??``,n.accountPassword.value=``,Q(i.account?.email??t),W()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}Z=``,Q(null),W()})()}),(async()=>{let e=[];try{let t=await fetch(`/api/v1/auth/providers`);t.ok&&(e=(await t.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0;for(let t of e){let e=document.createElement(`a`);e.className=`button`,e.href=`/api/v1/${encodeURIComponent(t.id)}/oauth/start`,e.textContent=`Continue with ${t.name}`,n.accountProviders.append(e)}})(),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Z=e.ok?t.account?.id??``:``,Q(e.ok?t.account?.email??`you`:null)}catch{Z=``,Q(null)}})(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,I()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await N(o.index)):b.stop(),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),P();return;case`s`:ce();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),M(Math.min(C()-1,E()+1));return;case`ArrowUp`:e.preventDefault(),M(Math.max(0,E()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(se);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(oe);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await x(e)===null)return;let t=await te(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,S.connect(e),I())})(),I(),requestAnimationFrame(R)}E(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
|