nixamp 0.7.41 → 0.9.3
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/catalogs.d.ts +100 -0
- package/dist/catalogs.js +0 -0
- package/dist/certs.d.ts +112 -0
- package/dist/certs.js +217 -0
- package/dist/channels.d.ts +17 -0
- package/dist/channels.js +45 -0
- package/dist/directory.d.ts +10 -0
- package/dist/directory.js +6 -0
- package/dist/dns.d.ts +63 -0
- package/dist/dns.js +171 -0
- package/dist/library.d.ts +36 -0
- package/dist/library.js +167 -0
- package/dist/main.js +92 -4
- package/dist/names.d.ts +66 -0
- package/dist/names.js +184 -0
- package/dist/naming.d.ts +57 -0
- package/dist/naming.js +150 -0
- package/dist/owner.js +4 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +3 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.js +418 -5
- package/dist/session.d.ts +8 -0
- package/dist/session.js +103 -0
- package/package.json +8 -2
- package/src/catalogs.ts +0 -0
- package/src/certs.ts +294 -0
- package/src/channels.ts +41 -0
- package/src/directory.ts +16 -0
- package/src/dns.ts +207 -0
- package/src/library.ts +175 -0
- package/src/main.ts +88 -4
- package/src/names.ts +239 -0
- package/src/naming.ts +197 -0
- package/src/owner.ts +3 -0
- package/src/publish.ts +5 -0
- package/src/server.ts +446 -5
- package/src/session.ts +117 -0
- package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-C88rYdXy.js} +1 -1
- package/web/dist/assets/index-BB3VT0Ks.js +1 -0
- package/web/dist/assets/index-DrXbwjOa.css +1 -0
- package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-BMDK3Ac9.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-C06vXzyy.js} +1 -1
- package/web/dist/index.html +23 -2
- package/web/dist/install.sh +3 -1
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-ComwKkzf.js +0 -1
- package/web/dist/assets/index-D3xGDAOd.css +0 -1
package/src/server.ts
CHANGED
|
@@ -55,6 +55,13 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
|
|
|
55
55
|
import pg from "pg";
|
|
56
56
|
import { Follows, phoneFrom } from "./follows.ts";
|
|
57
57
|
import { Favorites, favoriteUrl } from "./favorites.ts";
|
|
58
|
+
import { Catalogs, shownCatalog, shownEntry } from "./catalogs.ts";
|
|
59
|
+
import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
|
|
60
|
+
import { NameError, Names } from "./names.ts";
|
|
61
|
+
import { AcmeIssuer, Certs } from "./certs.ts";
|
|
62
|
+
import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
|
|
63
|
+
import { forbiddenLibrary, readLibrary } from "./library.ts";
|
|
64
|
+
import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
|
|
58
65
|
import { Durable } from "./durable.ts";
|
|
59
66
|
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
60
67
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
@@ -119,6 +126,8 @@ export interface ServeOptions {
|
|
|
119
126
|
newKey: boolean;
|
|
120
127
|
/** Start without the noise it makes when it wakes up. */
|
|
121
128
|
noJingle: boolean;
|
|
129
|
+
/** Do not ask nixamp.com for a name and a certificate, even when signed in. */
|
|
130
|
+
noName: boolean;
|
|
122
131
|
/**
|
|
123
132
|
* Ask the local firewall to let the port through, and put it back on the way
|
|
124
133
|
* out. Off by default because it changes the machine, not just this process.
|
|
@@ -202,7 +211,9 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
202
211
|
// A platform that hands out the port does it through PORT; a flag still wins.
|
|
203
212
|
const fromEnv = Number(process.env.PORT);
|
|
204
213
|
const options: ServeOptions = {
|
|
205
|
-
|
|
214
|
+
// Empty, not ".": nothing about a server should depend on where it was
|
|
215
|
+
// started from. The saved library fills it in, or the start refuses.
|
|
216
|
+
root: "",
|
|
206
217
|
port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
|
|
207
218
|
// Every interface, because a player nobody else can reach is not much of a
|
|
208
219
|
// remote. The key in the link is what makes that safe; --no-key gives up
|
|
@@ -213,6 +224,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
213
224
|
key: true,
|
|
214
225
|
newKey: false,
|
|
215
226
|
noJingle: false,
|
|
227
|
+
noName: false,
|
|
216
228
|
openPort: false,
|
|
217
229
|
announce: false,
|
|
218
230
|
directory: false,
|
|
@@ -289,6 +301,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
289
301
|
options.newKey = true;
|
|
290
302
|
} else if (arg === "--no-jingle") {
|
|
291
303
|
options.noJingle = true;
|
|
304
|
+
} else if (arg === "--no-name") {
|
|
305
|
+
options.noName = true;
|
|
292
306
|
} else if (arg === "--ingest") {
|
|
293
307
|
options.ingest = true;
|
|
294
308
|
} else if (arg === "--rtmp-streams") {
|
|
@@ -912,6 +926,44 @@ export class PlayerEngine implements Engine {
|
|
|
912
926
|
* looking at what is on wants "that album from the web", not every track in
|
|
913
927
|
* it. An entry names where to start, so clicking it plays.
|
|
914
928
|
*/
|
|
929
|
+
/**
|
|
930
|
+
* A fetch-shaped Request for the throttle, built from the Node one.
|
|
931
|
+
*
|
|
932
|
+
* @profullstack/throttle is written against the web Request so it runs at an
|
|
933
|
+
* edge; this server is Node's http. Only what the throttle reads is carried
|
|
934
|
+
* across: method, URL and headers. The body is not, because metering is
|
|
935
|
+
* decided before anybody reads it.
|
|
936
|
+
*/
|
|
937
|
+
export function requestFor(request: IncomingMessage, origin = "http://localhost"): Request {
|
|
938
|
+
const headers = new Headers();
|
|
939
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
940
|
+
if (typeof value === "string") headers.set(name, value);
|
|
941
|
+
else if (Array.isArray(value)) headers.set(name, value.join(", "));
|
|
942
|
+
}
|
|
943
|
+
// The address, for a throttle that has no socket to ask.
|
|
944
|
+
if (!headers.has("x-forwarded-for") && request.socket?.remoteAddress) {
|
|
945
|
+
headers.set("x-forwarded-for", request.socket.remoteAddress);
|
|
946
|
+
}
|
|
947
|
+
return new Request(`${origin}${request.url ?? "/"}`, { method: request.method ?? "GET", headers });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Write a refusal the throttle produced back through the Node response. */
|
|
951
|
+
export async function answerWith(response: ServerResponse, refused: Response): Promise<void> {
|
|
952
|
+
const headers: Record<string, string> = { ...CORS };
|
|
953
|
+
refused.headers.forEach((value, name) => {
|
|
954
|
+
headers[name] = value;
|
|
955
|
+
});
|
|
956
|
+
response.writeHead(refused.status, headers);
|
|
957
|
+
response.end(Buffer.from(await refused.arrayBuffer()));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* How many channels a server will start on demand at once. Each is an ffmpeg,
|
|
962
|
+
* and a catalog has thousands of entries; this is what keeps a room full of
|
|
963
|
+
* curious people from becoming a room full of decoders.
|
|
964
|
+
*/
|
|
965
|
+
export const MAX_ON_DEMAND = 4;
|
|
966
|
+
|
|
915
967
|
/**
|
|
916
968
|
* Probe a source and start carrying it as a channel of its own.
|
|
917
969
|
*
|
|
@@ -985,7 +1037,7 @@ const CORS: Record<string, string> = {
|
|
|
985
1037
|
// control API has to be reachable cross-origin. It exposes no filesystem
|
|
986
1038
|
// paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
|
|
987
1039
|
"access-control-allow-origin": "*",
|
|
988
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
1040
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
989
1041
|
"access-control-allow-headers": "content-type",
|
|
990
1042
|
"access-control-max-age": "86400",
|
|
991
1043
|
};
|
|
@@ -1095,6 +1147,8 @@ export interface HandlerOptions {
|
|
|
1095
1147
|
channels?: Channels;
|
|
1096
1148
|
/** Write down the channels this server pulls, so a restart puts them back. */
|
|
1097
1149
|
rememberChannels?: (list: RememberedChannel[]) => void;
|
|
1150
|
+
/** The m3u catalogs this server keeps, browsable by group. */
|
|
1151
|
+
catalogs?: Catalogs;
|
|
1098
1152
|
/** Live audio going out to RTMP. */
|
|
1099
1153
|
broadcaster?: Broadcaster;
|
|
1100
1154
|
/** Where a broadcast should send, and what it should look like. */
|
|
@@ -1171,6 +1225,18 @@ export interface HandlerOptions {
|
|
|
1171
1225
|
follows?: Follows;
|
|
1172
1226
|
/** The servers an account hearted. nixamp.com only, like follows. */
|
|
1173
1227
|
favorites?: Favorites;
|
|
1228
|
+
/** Names under `<handle>.<zone>` for an account's servers. nixamp.com only. */
|
|
1229
|
+
names?: Names;
|
|
1230
|
+
/** One wildcard certificate per handle, issued and renewed here. nixamp.com only. */
|
|
1231
|
+
certs?: Certs;
|
|
1232
|
+
/** The zone the names live in, e.g. "nixamp.com". */
|
|
1233
|
+
dnsZone?: string;
|
|
1234
|
+
/**
|
|
1235
|
+
* The rate limit over everything, from @profullstack/throttle. Fetch-shaped,
|
|
1236
|
+
* so the handler builds a Request from the Node one and writes back the
|
|
1237
|
+
* Response it is refused with.
|
|
1238
|
+
*/
|
|
1239
|
+
throttle?: Throttle;
|
|
1174
1240
|
/** The VAPID public key a browser needs before it can subscribe. */
|
|
1175
1241
|
vapidPublicKey?: string;
|
|
1176
1242
|
}
|
|
@@ -1219,6 +1285,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1219
1285
|
return;
|
|
1220
1286
|
}
|
|
1221
1287
|
|
|
1288
|
+
// Metered before anything is done for the request, so a caller over its
|
|
1289
|
+
// allowance costs nothing but this check. The throttle decides; this only
|
|
1290
|
+
// carries its refusal back through Node's response.
|
|
1291
|
+
if (options.throttle) {
|
|
1292
|
+
const refused = await options.throttle.handle(requestFor(request));
|
|
1293
|
+
if (refused) {
|
|
1294
|
+
await answerWith(response, refused);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1222
1299
|
// Opening a share link is what hands a browser its key. It comes back as a
|
|
1223
1300
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
1224
1301
|
// without the page knowing anything about keys. Either key works here, and
|
|
@@ -1261,6 +1338,99 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1261
1338
|
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
1262
1339
|
// account, and an account is the only thing that makes "notify me on my
|
|
1263
1340
|
// other device" mean anything.
|
|
1341
|
+
// --- names and certificates for an account's servers --------------------
|
|
1342
|
+
//
|
|
1343
|
+
// A server that is signed in becomes `<label>.<handle>.<zone>`, with A and
|
|
1344
|
+
// AAAA records nixamp.com writes with keys only nixamp.com holds, and it
|
|
1345
|
+
// serves https with the one wildcard certificate its handle has. Nothing
|
|
1346
|
+
// about DNS or ACME ever reaches the box; it asks, and is answered.
|
|
1347
|
+
if ((path === "/api/v1/dns" || path.startsWith("/api/v1/dns/")) && options.names && options.accounts && options.handles) {
|
|
1348
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1349
|
+
if (me === null) {
|
|
1350
|
+
json(response, 401, { error: "sign in to name a server" });
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const handle = await options.handles.of(me.id);
|
|
1354
|
+
if (!handle) {
|
|
1355
|
+
json(response, 422, { error: "this account has no handle yet" });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const names = options.names;
|
|
1359
|
+
const zone = `${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
|
|
1360
|
+
|
|
1361
|
+
if (path === "/api/v1/dns" && request.method === "GET") {
|
|
1362
|
+
json(response, 200, { zone, names: await names.list(me.id, handle) });
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const label = decodeURIComponent(path.slice("/api/v1/dns/".length));
|
|
1366
|
+
if (!label) {
|
|
1367
|
+
json(response, 404, { error: "no such endpoint" });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
1371
|
+
let body: { a?: unknown; aaaa?: unknown; ttl?: unknown } = {};
|
|
1372
|
+
try {
|
|
1373
|
+
body = JSON.parse((await readBody(request)) || "{}") as typeof body;
|
|
1374
|
+
} catch {
|
|
1375
|
+
json(response, 400, { error: "bad JSON" });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
// "auto" is the address this request came from, for whichever family
|
|
1379
|
+
// it came in on: a server names itself without knowing its address.
|
|
1380
|
+
const caller = callerOf(request.headers, request.socket.remoteAddress, options.behindProxy ?? false);
|
|
1381
|
+
const family = (value: unknown, is: (ip: unknown) => boolean): string | null | undefined => {
|
|
1382
|
+
if (value === null) return null;
|
|
1383
|
+
if (value === undefined) return undefined;
|
|
1384
|
+
if (value === "auto") return is(caller) ? caller : undefined;
|
|
1385
|
+
return String(value);
|
|
1386
|
+
};
|
|
1387
|
+
try {
|
|
1388
|
+
const name = await names.set(me.id, handle, label, {
|
|
1389
|
+
a: family(body.a, isIPv4),
|
|
1390
|
+
aaaa: family(body.aaaa, isIPv6),
|
|
1391
|
+
...(typeof body.ttl === "number" ? { ttl: body.ttl } : {}),
|
|
1392
|
+
});
|
|
1393
|
+
json(response, 200, { name });
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
const status = error instanceof NameError ? error.status : 500;
|
|
1396
|
+
json(response, status, { error: (error as Error).message });
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (request.method === "DELETE") {
|
|
1401
|
+
const gone = await names.remove(me.id, handle, label);
|
|
1402
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such name of yours" });
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
json(response, 405, { error: "GET, PUT or DELETE" });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (path === "/api/v1/certs" && options.certs && options.accounts && options.handles) {
|
|
1410
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1411
|
+
if (me === null) {
|
|
1412
|
+
json(response, 401, { error: "sign in to get a certificate" });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const handle = await options.handles.of(me.id);
|
|
1416
|
+
if (!handle) {
|
|
1417
|
+
json(response, 422, { error: "this account has no handle yet" });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const state = await options.certs.forHandle(handle);
|
|
1421
|
+
const host = `*.${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
|
|
1422
|
+
if (state.status === "ready") {
|
|
1423
|
+
json(response, 200, { status: "ready", cert: state.cert, key: state.key, expiresAt: state.expiresAt, host, renewing: state.renewing });
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (state.status === "failed") {
|
|
1427
|
+
json(response, 503, { status: "failed", error: state.error, host });
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
json(response, 202, { status: "issuing", host });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1264
1434
|
// Favourites: the servers you hearted, kept against your account. Reading
|
|
1265
1435
|
// the directory and listening need no account; remembering where you
|
|
1266
1436
|
// listened does, because there has to be somebody to remember it for.
|
|
@@ -2098,9 +2268,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2098
2268
|
// phone for it. The code is published on purpose: it is a public
|
|
2099
2269
|
// call-in line, and a listing you cannot dial is a listing of nothing.
|
|
2100
2270
|
const onThePhone = options.partyLine;
|
|
2101
|
-
|
|
2271
|
+
// The admin link goes only to the account that owns the listing. A
|
|
2272
|
+
// directory that handed out control links would be a directory of
|
|
2273
|
+
// machines anyone could take over.
|
|
2274
|
+
const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)) : null;
|
|
2275
|
+
const streams = options.directory.list().map(({ admin, ...stream }) => ({
|
|
2102
2276
|
...stream,
|
|
2103
2277
|
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
2278
|
+
...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
|
|
2104
2279
|
}));
|
|
2105
2280
|
// Recently ended too, because following exists to hear about
|
|
2106
2281
|
// broadcasts you would otherwise miss -- and a list of only what is on
|
|
@@ -2293,6 +2468,140 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2293
2468
|
|
|
2294
2469
|
// --- several streams at once ------------------------------------------
|
|
2295
2470
|
//
|
|
2471
|
+
// --- catalogs: m3u lists you can browse ---------------------------------
|
|
2472
|
+
//
|
|
2473
|
+
// An IPTV list is thousands of entries with groups and logos. Kept as a
|
|
2474
|
+
// catalog it stays browsable; poured into the playlist it was three
|
|
2475
|
+
// thousand flat rows. Anyone with the link browses and plays; adding,
|
|
2476
|
+
// refreshing and removing is administering (see needsAdmin).
|
|
2477
|
+
if ((path === "/api/catalogs" || path.startsWith("/api/catalogs/")) && options.catalogs) {
|
|
2478
|
+
const catalogs = options.catalogs;
|
|
2479
|
+
|
|
2480
|
+
if (path === "/api/catalogs" && request.method === "GET") {
|
|
2481
|
+
// Where a list is read from is the administrator's business, not a
|
|
2482
|
+
// listener's: it can carry a provider's credentials in the URL.
|
|
2483
|
+
const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
2484
|
+
const admin = options.owner
|
|
2485
|
+
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
2486
|
+
: holdsControl;
|
|
2487
|
+
json(response, 200, { catalogs: catalogs.list().map((one) => shownCatalog(one, admin)) });
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (path === "/api/catalogs" && request.method === "POST") {
|
|
2492
|
+
let body: { source?: unknown; name?: unknown } = {};
|
|
2493
|
+
try {
|
|
2494
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2495
|
+
} catch {
|
|
2496
|
+
json(response, 400, { error: "bad JSON" });
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
try {
|
|
2500
|
+
const added = await catalogs.add(String(body.source ?? ""), String(body.name ?? ""));
|
|
2501
|
+
json(response, added.error ? 422 : 200, {
|
|
2502
|
+
ok: !added.error,
|
|
2503
|
+
catalog: shownCatalog(added, true),
|
|
2504
|
+
...(added.error ? { error: added.error } : {}),
|
|
2505
|
+
});
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
|
|
2508
|
+
}
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
const [rawId = "", action = "", entryId = "", sub = ""] = path.slice("/api/catalogs/".length).split("/");
|
|
2513
|
+
const id = decodeURIComponent(rawId);
|
|
2514
|
+
if (!catalogs.get(id)) {
|
|
2515
|
+
json(response, 404, { error: "no such catalog" });
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
if (action === "" && request.method === "DELETE") {
|
|
2520
|
+
json(response, 200, { ok: catalogs.remove(id) });
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (action === "refresh" && request.method === "POST") {
|
|
2524
|
+
const refreshed = await catalogs.refresh(id);
|
|
2525
|
+
json(response, refreshed && !refreshed.error ? 200 : 422, {
|
|
2526
|
+
ok: refreshed !== null && !refreshed.error,
|
|
2527
|
+
...(refreshed ? { catalog: shownCatalog(refreshed, true) } : {}),
|
|
2528
|
+
...(refreshed?.error ? { error: refreshed.error } : {}),
|
|
2529
|
+
});
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
if (action === "groups" && request.method === "GET") {
|
|
2533
|
+
json(response, 200, { groups: catalogs.groups(id) ?? [] });
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
if (action === "entries" && entryId === "" && request.method === "GET") {
|
|
2537
|
+
const page = catalogs.entries_(id, {
|
|
2538
|
+
group: url.searchParams.get("group") ?? "",
|
|
2539
|
+
q: url.searchParams.get("q") ?? "",
|
|
2540
|
+
offset: Number(url.searchParams.get("offset") ?? "0") || 0,
|
|
2541
|
+
limit: Number(url.searchParams.get("limit") ?? "200") || 200,
|
|
2542
|
+
}) ?? { total: 0, entries: [] };
|
|
2543
|
+
json(response, 200, { total: page.total, entries: page.entries.map(shownEntry) });
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const entry = action === "entries" && entryId !== "" ? catalogs.entry(id, decodeURIComponent(entryId)) : null;
|
|
2548
|
+
if (!entry) {
|
|
2549
|
+
json(response, 404, { error: "no such entry" });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// Play. A live entry becomes a channel, started for whoever asked and
|
|
2554
|
+
// stopped a minute after the last viewer leaves; a film is played on
|
|
2555
|
+
// its own, straight from the source through ffmpeg.
|
|
2556
|
+
if (sub === "play" && request.method === "POST") {
|
|
2557
|
+
if (!entry.live) {
|
|
2558
|
+
json(response, 200, {
|
|
2559
|
+
kind: "vod",
|
|
2560
|
+
url: `/api/catalogs/${encodeURIComponent(id)}/entries/${encodeURIComponent(entry.id)}/stream`,
|
|
2561
|
+
name: entry.title,
|
|
2562
|
+
});
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
if (!options.channels) {
|
|
2566
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
const channelId = cleanId(`cat-${entry.id}`);
|
|
2570
|
+
if (!options.channels.has(channelId)) {
|
|
2571
|
+
if (options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2572
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
|
|
2576
|
+
if (!started) {
|
|
2577
|
+
json(response, 409, { error: "that channel is already starting" });
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
options.channels.ephemeral(channelId);
|
|
2581
|
+
}
|
|
2582
|
+
json(response, 200, { kind: "live", channel: channelId, name: entry.title });
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
if (sub === "stream" && request.method === "GET") {
|
|
2587
|
+
if (!options.media) {
|
|
2588
|
+
json(response, 403, { error: "media streaming is off" });
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
watch(request, response, "stream", entry.title);
|
|
2592
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
|
|
2593
|
+
if (codecs.video !== "") {
|
|
2594
|
+
pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
2595
|
+
} else {
|
|
2596
|
+
transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
|
|
2597
|
+
}
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
json(response, 404, { error: "no such endpoint" });
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2296
2605
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
2297
2606
|
// devices can publish at once, each to their own channel, and a listener
|
|
2298
2607
|
// picks which to hear.
|
|
@@ -3323,7 +3632,20 @@ export function createServer(engine: Engine, options: HandlerOptions): Server {
|
|
|
3323
3632
|
|
|
3324
3633
|
export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
3325
3634
|
const options = parseServeArgs(argv);
|
|
3326
|
-
|
|
3635
|
+
// Told which folder, or the one that was saved. Never the directory this
|
|
3636
|
+
// happens to be running in: a daemon restarted from a home directory served
|
|
3637
|
+
// the home directory, keys and all, under a public listing.
|
|
3638
|
+
const chosen = options.root || readLibrary();
|
|
3639
|
+
if (!chosen) {
|
|
3640
|
+
throw new Error(
|
|
3641
|
+
"nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.",
|
|
3642
|
+
);
|
|
3643
|
+
}
|
|
3644
|
+
const root = isRemote(chosen) ? chosen : resolve(chosen);
|
|
3645
|
+
const why = isRemote(root) ? "" : forbiddenLibrary(root);
|
|
3646
|
+
if (why) {
|
|
3647
|
+
throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
|
|
3648
|
+
}
|
|
3327
3649
|
const tools = detectTools();
|
|
3328
3650
|
// Names now, tags later.
|
|
3329
3651
|
//
|
|
@@ -3393,6 +3715,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3393
3715
|
});
|
|
3394
3716
|
}
|
|
3395
3717
|
|
|
3718
|
+
// The m3u catalogs kept here: read from disk now, and any that were never
|
|
3719
|
+
// read are fetched in the background so browsing does not wait on a provider.
|
|
3720
|
+
const catalogs = new Catalogs(stateDir(), options.port);
|
|
3721
|
+
catalogs.load();
|
|
3722
|
+
void catalogs.warm().catch(() => undefined);
|
|
3723
|
+
|
|
3396
3724
|
const destinations = parseDestinations(options.rtmp);
|
|
3397
3725
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
3398
3726
|
const ingest = options.ingest
|
|
@@ -3416,6 +3744,54 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3416
3744
|
: undefined;
|
|
3417
3745
|
const follows = pool ? new Follows(pool) : undefined;
|
|
3418
3746
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
3747
|
+
|
|
3748
|
+
// Names and certificates for signed-in servers, and the rate limit over
|
|
3749
|
+
// everything. All of it is nixamp.com's business: the DNS keys live only
|
|
3750
|
+
// here, the certificates are issued here, and a personal nixamp has neither
|
|
3751
|
+
// a database nor strangers to meter. Without the registrar's keys the names
|
|
3752
|
+
// are simply not offered, rather than written into a zone that does not
|
|
3753
|
+
// exist.
|
|
3754
|
+
const zoneName = (() => {
|
|
3755
|
+
try {
|
|
3756
|
+
return new URL(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).hostname;
|
|
3757
|
+
} catch {
|
|
3758
|
+
return "nixamp.com";
|
|
3759
|
+
}
|
|
3760
|
+
})();
|
|
3761
|
+
const porkbunKey = process.env["PORKBUN_API_KEY"] ?? "";
|
|
3762
|
+
const porkbunSecret = process.env["PORKBUN_SECRET_API_KEY"] ?? "";
|
|
3763
|
+
const zone: DnsZone | null = porkbunKey && porkbunSecret ? new Porkbun(zoneName, porkbunKey, porkbunSecret) : null;
|
|
3764
|
+
const names = pool && zone ? new Names(pool, zone) : undefined;
|
|
3765
|
+
let certs: Certs | undefined;
|
|
3766
|
+
if (pool && zone) {
|
|
3767
|
+
const issuer = new AcmeIssuer({
|
|
3768
|
+
directoryUrl: process.env["NIXAMP_ACME_DIRECTORY"] ?? "https://acme-v02.api.letsencrypt.org/directory",
|
|
3769
|
+
email: process.env["NIXAMP_ACME_EMAIL"] ?? `hostmaster@${zoneName}`,
|
|
3770
|
+
// The key is kept by the store, so the issuer asks for it each time
|
|
3771
|
+
// rather than holding one that a second instance would not share.
|
|
3772
|
+
accountKey: () => (certs as Certs).accountKey(),
|
|
3773
|
+
});
|
|
3774
|
+
certs = new Certs(pool, zone, issuer, { log: (line) => console.log(` ${line}`) });
|
|
3775
|
+
}
|
|
3776
|
+
const throttle = pool
|
|
3777
|
+
? createThrottle({
|
|
3778
|
+
rules: [
|
|
3779
|
+
// Sign-in stays address-bucketed however the request is dressed, or
|
|
3780
|
+
// a guess with an Authorization header buys itself the bigger budget.
|
|
3781
|
+
{ path: "/api/v1/auth/", limit: 20, credential: false },
|
|
3782
|
+
{ path: "/api/v1/dns/", limit: 30 },
|
|
3783
|
+
{ path: "/api/v1/dns", limit: 30 },
|
|
3784
|
+
{ path: "/api/v1/certs", limit: 30 },
|
|
3785
|
+
{ path: "/api/health", open: true },
|
|
3786
|
+
{ path: "/api/directory", limit: 120 },
|
|
3787
|
+
],
|
|
3788
|
+
// A signed-in browser carries its session as a cookie, and is a
|
|
3789
|
+
// credential the same as a bearer token: a person on a dashboard is
|
|
3790
|
+
// not an anonymous scraper.
|
|
3791
|
+
credentialFrom: (request) =>
|
|
3792
|
+
presentedCredential(request.headers) ?? (tokenFrom(Object.fromEntries(request.headers)) || null),
|
|
3793
|
+
})
|
|
3794
|
+
: undefined;
|
|
3419
3795
|
// The two things that were promises kept only in memory: a caller who was
|
|
3420
3796
|
// told they would be texted, and the ended stream a code still points at.
|
|
3421
3797
|
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
@@ -3567,7 +3943,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3567
3943
|
|
|
3568
3944
|
// Read before listening, so a missing or unreadable certificate is a sentence
|
|
3569
3945
|
// now rather than a connection that resets later.
|
|
3570
|
-
|
|
3946
|
+
let tls: { cert: string; key: string } | undefined = options.tlsCert
|
|
3571
3947
|
? (() => {
|
|
3572
3948
|
try {
|
|
3573
3949
|
return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
|
|
@@ -3577,6 +3953,45 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3577
3953
|
})()
|
|
3578
3954
|
: undefined;
|
|
3579
3955
|
|
|
3956
|
+
// A signed-in server names itself.
|
|
3957
|
+
//
|
|
3958
|
+
// Nothing about DNS or certificates reaches this machine: it asks nixamp.com
|
|
3959
|
+
// for `<label>.<handle>.<zone>` pointing at the address it is calling from,
|
|
3960
|
+
// and for the handle's wildcard certificate, and serves https under that
|
|
3961
|
+
// name. The registrar's keys stay on nixamp.com. Skipped when the operator
|
|
3962
|
+
// named or certified the server by hand, when it listens on one interface
|
|
3963
|
+
// only, or with --no-name.
|
|
3964
|
+
let certExpiresAt = 0;
|
|
3965
|
+
let namedHost = "";
|
|
3966
|
+
const namedSession = readSession();
|
|
3967
|
+
if (
|
|
3968
|
+
!options.noName && !options.publicUrl && !options.tlsCert &&
|
|
3969
|
+
(options.host === "0.0.0.0" || options.host === "::") && namedSession?.token
|
|
3970
|
+
) {
|
|
3971
|
+
const say = (line: string): void => console.log(` ${line}`);
|
|
3972
|
+
const named = await claimName(namedSession.site, namedSession.token, labelFor(options.name, hostname()), say);
|
|
3973
|
+
if (named) {
|
|
3974
|
+
namedHost = named.host;
|
|
3975
|
+
// The certificate is the handle's, so the cache is keyed by the handle's
|
|
3976
|
+
// wildcard rather than by this machine's label.
|
|
3977
|
+
const wildcard = `*.${named.host.split(".").slice(1).join(".")}`;
|
|
3978
|
+
let files = readCertFiles(stateDir(), wildcard);
|
|
3979
|
+
if (!files) {
|
|
3980
|
+
const got = await fetchCert(namedSession.site, namedSession.token, {}, say);
|
|
3981
|
+
if (got) {
|
|
3982
|
+
writeCertFiles(stateDir(), got);
|
|
3983
|
+
files = { cert: got.cert, key: got.key, expiresAt: got.expiresAt };
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
if (files) {
|
|
3987
|
+
tls = { cert: files.cert, key: files.key };
|
|
3988
|
+
certExpiresAt = files.expiresAt;
|
|
3989
|
+
}
|
|
3990
|
+
options.publicUrl = `${tls ? "https" : "http"}://${named.host}:${options.port}`;
|
|
3991
|
+
console.log(` This server is ${named.host}${tls ? "" : " -- no certificate yet, so http for now"}.`);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3580
3995
|
// Filled in below, when the RTMP listeners are opened. Read through a
|
|
3581
3996
|
// function so the handler sees the list rather than the empty array it was
|
|
3582
3997
|
// built with.
|
|
@@ -3595,6 +4010,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3595
4010
|
owner,
|
|
3596
4011
|
channels,
|
|
3597
4012
|
rememberChannels: remembering,
|
|
4013
|
+
catalogs,
|
|
3598
4014
|
publishUrls: () => publishUrls,
|
|
3599
4015
|
serverName: options.name || hostname(),
|
|
3600
4016
|
homeSource: root,
|
|
@@ -3652,6 +4068,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3652
4068
|
...(directory ? { directory } : {}),
|
|
3653
4069
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
3654
4070
|
...(favorites ? { favorites } : {}),
|
|
4071
|
+
...(names ? { names } : {}),
|
|
4072
|
+
...(certs ? { certs } : {}),
|
|
4073
|
+
dnsZone: zoneName,
|
|
4074
|
+
...(throttle ? { throttle } : {}),
|
|
3655
4075
|
...(partyLine ? { partyLine } : {}),
|
|
3656
4076
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
3657
4077
|
// laptop has nobody to be an account of.
|
|
@@ -3704,6 +4124,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3704
4124
|
const bound = server.address();
|
|
3705
4125
|
const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
3706
4126
|
|
|
4127
|
+
// A named server keeps its certificate fresh without a restart: once a day
|
|
4128
|
+
// it asks for the handle's certificate again and, when a newer one has been
|
|
4129
|
+
// issued, swaps it into the running listener.
|
|
4130
|
+
if (namedHost && namedSession?.token) {
|
|
4131
|
+
const renew = setInterval(() => {
|
|
4132
|
+
void fetchCert(namedSession.site, namedSession.token, { waitMs: 0 }, () => undefined).then((got) => {
|
|
4133
|
+
if (!got || got.expiresAt <= certExpiresAt) return;
|
|
4134
|
+
writeCertFiles(stateDir(), got);
|
|
4135
|
+
certExpiresAt = got.expiresAt;
|
|
4136
|
+
const secure = server as unknown as { setSecureContext?: (context: { cert: string; key: string }) => void };
|
|
4137
|
+
secure.setSecureContext?.({ cert: got.cert, key: got.key });
|
|
4138
|
+
console.log(` Renewed the certificate for ${namedHost}.`);
|
|
4139
|
+
});
|
|
4140
|
+
}, 24 * 60 * 60 * 1000);
|
|
4141
|
+
renew.unref();
|
|
4142
|
+
}
|
|
4143
|
+
|
|
3707
4144
|
const io = {
|
|
3708
4145
|
read: readIfPossible,
|
|
3709
4146
|
run: (command: string, args: string[]) => {
|
|
@@ -3922,6 +4359,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3922
4359
|
name: options.name || hostname(),
|
|
3923
4360
|
url: listen,
|
|
3924
4361
|
audio,
|
|
4362
|
+
// The control link, for the owner to open this machine as its
|
|
4363
|
+
// administrator from the directory. The directory shows it to the
|
|
4364
|
+
// owning account and strips it for everyone else.
|
|
4365
|
+
...(key ? { admin: shareLink(publishable_.url, key) } : {}),
|
|
3925
4366
|
// Asked at every heartbeat rather than once, because the library is
|
|
3926
4367
|
// read after the port opens and is still arriving when this is made.
|
|
3927
4368
|
tracks: () => engine.snapshot(false).trackCount,
|