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/dist/server.js
CHANGED
|
@@ -38,6 +38,13 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
|
38
38
|
import pg from "pg";
|
|
39
39
|
import { Follows, phoneFrom } from "./follows.js";
|
|
40
40
|
import { Favorites, favoriteUrl } from "./favorites.js";
|
|
41
|
+
import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
|
|
42
|
+
import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
|
|
43
|
+
import { NameError, Names } from "./names.js";
|
|
44
|
+
import { AcmeIssuer, Certs } from "./certs.js";
|
|
45
|
+
import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
46
|
+
import { forbiddenLibrary, readLibrary } from "./library.js";
|
|
47
|
+
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
41
48
|
import { Durable } from "./durable.js";
|
|
42
49
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
43
50
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
@@ -62,7 +69,9 @@ export function parseServeArgs(argv) {
|
|
|
62
69
|
// A platform that hands out the port does it through PORT; a flag still wins.
|
|
63
70
|
const fromEnv = Number(process.env.PORT);
|
|
64
71
|
const options = {
|
|
65
|
-
|
|
72
|
+
// Empty, not ".": nothing about a server should depend on where it was
|
|
73
|
+
// started from. The saved library fills it in, or the start refuses.
|
|
74
|
+
root: "",
|
|
66
75
|
port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
|
|
67
76
|
// Every interface, because a player nobody else can reach is not much of a
|
|
68
77
|
// remote. The key in the link is what makes that safe; --no-key gives up
|
|
@@ -73,6 +82,7 @@ export function parseServeArgs(argv) {
|
|
|
73
82
|
key: true,
|
|
74
83
|
newKey: false,
|
|
75
84
|
noJingle: false,
|
|
85
|
+
noName: false,
|
|
76
86
|
openPort: false,
|
|
77
87
|
announce: false,
|
|
78
88
|
directory: false,
|
|
@@ -168,6 +178,9 @@ export function parseServeArgs(argv) {
|
|
|
168
178
|
else if (arg === "--no-jingle") {
|
|
169
179
|
options.noJingle = true;
|
|
170
180
|
}
|
|
181
|
+
else if (arg === "--no-name") {
|
|
182
|
+
options.noName = true;
|
|
183
|
+
}
|
|
171
184
|
else if (arg === "--ingest") {
|
|
172
185
|
options.ingest = true;
|
|
173
186
|
}
|
|
@@ -708,6 +721,43 @@ export class PlayerEngine {
|
|
|
708
721
|
* looking at what is on wants "that album from the web", not every track in
|
|
709
722
|
* it. An entry names where to start, so clicking it plays.
|
|
710
723
|
*/
|
|
724
|
+
/**
|
|
725
|
+
* A fetch-shaped Request for the throttle, built from the Node one.
|
|
726
|
+
*
|
|
727
|
+
* @profullstack/throttle is written against the web Request so it runs at an
|
|
728
|
+
* edge; this server is Node's http. Only what the throttle reads is carried
|
|
729
|
+
* across: method, URL and headers. The body is not, because metering is
|
|
730
|
+
* decided before anybody reads it.
|
|
731
|
+
*/
|
|
732
|
+
export function requestFor(request, origin = "http://localhost") {
|
|
733
|
+
const headers = new Headers();
|
|
734
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
735
|
+
if (typeof value === "string")
|
|
736
|
+
headers.set(name, value);
|
|
737
|
+
else if (Array.isArray(value))
|
|
738
|
+
headers.set(name, value.join(", "));
|
|
739
|
+
}
|
|
740
|
+
// The address, for a throttle that has no socket to ask.
|
|
741
|
+
if (!headers.has("x-forwarded-for") && request.socket?.remoteAddress) {
|
|
742
|
+
headers.set("x-forwarded-for", request.socket.remoteAddress);
|
|
743
|
+
}
|
|
744
|
+
return new Request(`${origin}${request.url ?? "/"}`, { method: request.method ?? "GET", headers });
|
|
745
|
+
}
|
|
746
|
+
/** Write a refusal the throttle produced back through the Node response. */
|
|
747
|
+
export async function answerWith(response, refused) {
|
|
748
|
+
const headers = { ...CORS };
|
|
749
|
+
refused.headers.forEach((value, name) => {
|
|
750
|
+
headers[name] = value;
|
|
751
|
+
});
|
|
752
|
+
response.writeHead(refused.status, headers);
|
|
753
|
+
response.end(Buffer.from(await refused.arrayBuffer()));
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* How many channels a server will start on demand at once. Each is an ffmpeg,
|
|
757
|
+
* and a catalog has thousands of entries; this is what keeps a room full of
|
|
758
|
+
* curious people from becoming a room full of decoders.
|
|
759
|
+
*/
|
|
760
|
+
export const MAX_ON_DEMAND = 4;
|
|
711
761
|
/**
|
|
712
762
|
* Probe a source and start carrying it as a channel of its own.
|
|
713
763
|
*
|
|
@@ -777,7 +827,7 @@ const CORS = {
|
|
|
777
827
|
// control API has to be reachable cross-origin. It exposes no filesystem
|
|
778
828
|
// paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
|
|
779
829
|
"access-control-allow-origin": "*",
|
|
780
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
830
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
781
831
|
"access-control-allow-headers": "content-type",
|
|
782
832
|
"access-control-max-age": "86400",
|
|
783
833
|
};
|
|
@@ -871,6 +921,16 @@ export function createHandler(engine, options) {
|
|
|
871
921
|
response.end();
|
|
872
922
|
return;
|
|
873
923
|
}
|
|
924
|
+
// Metered before anything is done for the request, so a caller over its
|
|
925
|
+
// allowance costs nothing but this check. The throttle decides; this only
|
|
926
|
+
// carries its refusal back through Node's response.
|
|
927
|
+
if (options.throttle) {
|
|
928
|
+
const refused = await options.throttle.handle(requestFor(request));
|
|
929
|
+
if (refused) {
|
|
930
|
+
await answerWith(response, refused);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
874
934
|
// Opening a share link is what hands a browser its key. It comes back as a
|
|
875
935
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
876
936
|
// without the page knowing anything about keys. Either key works here, and
|
|
@@ -911,6 +971,101 @@ export function createHandler(engine, options) {
|
|
|
911
971
|
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
912
972
|
// account, and an account is the only thing that makes "notify me on my
|
|
913
973
|
// other device" mean anything.
|
|
974
|
+
// --- names and certificates for an account's servers --------------------
|
|
975
|
+
//
|
|
976
|
+
// A server that is signed in becomes `<label>.<handle>.<zone>`, with A and
|
|
977
|
+
// AAAA records nixamp.com writes with keys only nixamp.com holds, and it
|
|
978
|
+
// serves https with the one wildcard certificate its handle has. Nothing
|
|
979
|
+
// about DNS or ACME ever reaches the box; it asks, and is answered.
|
|
980
|
+
if ((path === "/api/v1/dns" || path.startsWith("/api/v1/dns/")) && options.names && options.accounts && options.handles) {
|
|
981
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
982
|
+
if (me === null) {
|
|
983
|
+
json(response, 401, { error: "sign in to name a server" });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const handle = await options.handles.of(me.id);
|
|
987
|
+
if (!handle) {
|
|
988
|
+
json(response, 422, { error: "this account has no handle yet" });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const names = options.names;
|
|
992
|
+
const zone = `${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
|
|
993
|
+
if (path === "/api/v1/dns" && request.method === "GET") {
|
|
994
|
+
json(response, 200, { zone, names: await names.list(me.id, handle) });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const label = decodeURIComponent(path.slice("/api/v1/dns/".length));
|
|
998
|
+
if (!label) {
|
|
999
|
+
json(response, 404, { error: "no such endpoint" });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
1003
|
+
let body = {};
|
|
1004
|
+
try {
|
|
1005
|
+
body = JSON.parse((await readBody(request)) || "{}");
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
json(response, 400, { error: "bad JSON" });
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
// "auto" is the address this request came from, for whichever family
|
|
1012
|
+
// it came in on: a server names itself without knowing its address.
|
|
1013
|
+
const caller = callerOf(request.headers, request.socket.remoteAddress, options.behindProxy ?? false);
|
|
1014
|
+
const family = (value, is) => {
|
|
1015
|
+
if (value === null)
|
|
1016
|
+
return null;
|
|
1017
|
+
if (value === undefined)
|
|
1018
|
+
return undefined;
|
|
1019
|
+
if (value === "auto")
|
|
1020
|
+
return is(caller) ? caller : undefined;
|
|
1021
|
+
return String(value);
|
|
1022
|
+
};
|
|
1023
|
+
try {
|
|
1024
|
+
const name = await names.set(me.id, handle, label, {
|
|
1025
|
+
a: family(body.a, isIPv4),
|
|
1026
|
+
aaaa: family(body.aaaa, isIPv6),
|
|
1027
|
+
...(typeof body.ttl === "number" ? { ttl: body.ttl } : {}),
|
|
1028
|
+
});
|
|
1029
|
+
json(response, 200, { name });
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
const status = error instanceof NameError ? error.status : 500;
|
|
1033
|
+
json(response, status, { error: error.message });
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (request.method === "DELETE") {
|
|
1038
|
+
const gone = await names.remove(me.id, handle, label);
|
|
1039
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such name of yours" });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
json(response, 405, { error: "GET, PUT or DELETE" });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (path === "/api/v1/certs" && options.certs && options.accounts && options.handles) {
|
|
1046
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1047
|
+
if (me === null) {
|
|
1048
|
+
json(response, 401, { error: "sign in to get a certificate" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const handle = await options.handles.of(me.id);
|
|
1052
|
+
if (!handle) {
|
|
1053
|
+
json(response, 422, { error: "this account has no handle yet" });
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const state = await options.certs.forHandle(handle);
|
|
1057
|
+
const host = `*.${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
|
|
1058
|
+
if (state.status === "ready") {
|
|
1059
|
+
json(response, 200, { status: "ready", cert: state.cert, key: state.key, expiresAt: state.expiresAt, host, renewing: state.renewing });
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (state.status === "failed") {
|
|
1063
|
+
json(response, 503, { status: "failed", error: state.error, host });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
json(response, 202, { status: "issuing", host });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
914
1069
|
// Favourites: the servers you hearted, kept against your account. Reading
|
|
915
1070
|
// the directory and listening need no account; remembering where you
|
|
916
1071
|
// listened does, because there has to be somebody to remember it for.
|
|
@@ -1688,9 +1843,14 @@ export function createHandler(engine, options) {
|
|
|
1688
1843
|
// phone for it. The code is published on purpose: it is a public
|
|
1689
1844
|
// call-in line, and a listing you cannot dial is a listing of nothing.
|
|
1690
1845
|
const onThePhone = options.partyLine;
|
|
1691
|
-
|
|
1846
|
+
// The admin link goes only to the account that owns the listing. A
|
|
1847
|
+
// directory that handed out control links would be a directory of
|
|
1848
|
+
// machines anyone could take over.
|
|
1849
|
+
const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)) : null;
|
|
1850
|
+
const streams = options.directory.list().map(({ admin, ...stream }) => ({
|
|
1692
1851
|
...stream,
|
|
1693
1852
|
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
1853
|
+
...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
|
|
1694
1854
|
}));
|
|
1695
1855
|
// Recently ended too, because following exists to hear about
|
|
1696
1856
|
// broadcasts you would otherwise miss -- and a list of only what is on
|
|
@@ -1876,6 +2036,134 @@ export function createHandler(engine, options) {
|
|
|
1876
2036
|
}
|
|
1877
2037
|
// --- several streams at once ------------------------------------------
|
|
1878
2038
|
//
|
|
2039
|
+
// --- catalogs: m3u lists you can browse ---------------------------------
|
|
2040
|
+
//
|
|
2041
|
+
// An IPTV list is thousands of entries with groups and logos. Kept as a
|
|
2042
|
+
// catalog it stays browsable; poured into the playlist it was three
|
|
2043
|
+
// thousand flat rows. Anyone with the link browses and plays; adding,
|
|
2044
|
+
// refreshing and removing is administering (see needsAdmin).
|
|
2045
|
+
if ((path === "/api/catalogs" || path.startsWith("/api/catalogs/")) && options.catalogs) {
|
|
2046
|
+
const catalogs = options.catalogs;
|
|
2047
|
+
if (path === "/api/catalogs" && request.method === "GET") {
|
|
2048
|
+
// Where a list is read from is the administrator's business, not a
|
|
2049
|
+
// listener's: it can carry a provider's credentials in the URL.
|
|
2050
|
+
const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
2051
|
+
const admin = options.owner
|
|
2052
|
+
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
2053
|
+
: holdsControl;
|
|
2054
|
+
json(response, 200, { catalogs: catalogs.list().map((one) => shownCatalog(one, admin)) });
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (path === "/api/catalogs" && request.method === "POST") {
|
|
2058
|
+
let body = {};
|
|
2059
|
+
try {
|
|
2060
|
+
body = JSON.parse(await readBody(request));
|
|
2061
|
+
}
|
|
2062
|
+
catch {
|
|
2063
|
+
json(response, 400, { error: "bad JSON" });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
const added = await catalogs.add(String(body.source ?? ""), String(body.name ?? ""));
|
|
2068
|
+
json(response, added.error ? 422 : 200, {
|
|
2069
|
+
ok: !added.error,
|
|
2070
|
+
catalog: shownCatalog(added, true),
|
|
2071
|
+
...(added.error ? { error: added.error } : {}),
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
catch (error) {
|
|
2075
|
+
json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
|
|
2076
|
+
}
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const [rawId = "", action = "", entryId = "", sub = ""] = path.slice("/api/catalogs/".length).split("/");
|
|
2080
|
+
const id = decodeURIComponent(rawId);
|
|
2081
|
+
if (!catalogs.get(id)) {
|
|
2082
|
+
json(response, 404, { error: "no such catalog" });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
if (action === "" && request.method === "DELETE") {
|
|
2086
|
+
json(response, 200, { ok: catalogs.remove(id) });
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (action === "refresh" && request.method === "POST") {
|
|
2090
|
+
const refreshed = await catalogs.refresh(id);
|
|
2091
|
+
json(response, refreshed && !refreshed.error ? 200 : 422, {
|
|
2092
|
+
ok: refreshed !== null && !refreshed.error,
|
|
2093
|
+
...(refreshed ? { catalog: shownCatalog(refreshed, true) } : {}),
|
|
2094
|
+
...(refreshed?.error ? { error: refreshed.error } : {}),
|
|
2095
|
+
});
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (action === "groups" && request.method === "GET") {
|
|
2099
|
+
json(response, 200, { groups: catalogs.groups(id) ?? [] });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
if (action === "entries" && entryId === "" && request.method === "GET") {
|
|
2103
|
+
const page = catalogs.entries_(id, {
|
|
2104
|
+
group: url.searchParams.get("group") ?? "",
|
|
2105
|
+
q: url.searchParams.get("q") ?? "",
|
|
2106
|
+
offset: Number(url.searchParams.get("offset") ?? "0") || 0,
|
|
2107
|
+
limit: Number(url.searchParams.get("limit") ?? "200") || 200,
|
|
2108
|
+
}) ?? { total: 0, entries: [] };
|
|
2109
|
+
json(response, 200, { total: page.total, entries: page.entries.map(shownEntry) });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
const entry = action === "entries" && entryId !== "" ? catalogs.entry(id, decodeURIComponent(entryId)) : null;
|
|
2113
|
+
if (!entry) {
|
|
2114
|
+
json(response, 404, { error: "no such entry" });
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
// Play. A live entry becomes a channel, started for whoever asked and
|
|
2118
|
+
// stopped a minute after the last viewer leaves; a film is played on
|
|
2119
|
+
// its own, straight from the source through ffmpeg.
|
|
2120
|
+
if (sub === "play" && request.method === "POST") {
|
|
2121
|
+
if (!entry.live) {
|
|
2122
|
+
json(response, 200, {
|
|
2123
|
+
kind: "vod",
|
|
2124
|
+
url: `/api/catalogs/${encodeURIComponent(id)}/entries/${encodeURIComponent(entry.id)}/stream`,
|
|
2125
|
+
name: entry.title,
|
|
2126
|
+
});
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
if (!options.channels) {
|
|
2130
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const channelId = cleanId(`cat-${entry.id}`);
|
|
2134
|
+
if (!options.channels.has(channelId)) {
|
|
2135
|
+
if (options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2136
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
|
|
2140
|
+
if (!started) {
|
|
2141
|
+
json(response, 409, { error: "that channel is already starting" });
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
options.channels.ephemeral(channelId);
|
|
2145
|
+
}
|
|
2146
|
+
json(response, 200, { kind: "live", channel: channelId, name: entry.title });
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (sub === "stream" && request.method === "GET") {
|
|
2150
|
+
if (!options.media) {
|
|
2151
|
+
json(response, 403, { error: "media streaming is off" });
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
watch(request, response, "stream", entry.title);
|
|
2155
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
|
|
2156
|
+
if (codecs.video !== "") {
|
|
2157
|
+
pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
2158
|
+
}
|
|
2159
|
+
else {
|
|
2160
|
+
transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
|
|
2161
|
+
}
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
json(response, 404, { error: "no such endpoint" });
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
1879
2167
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
1880
2168
|
// devices can publish at once, each to their own channel, and a listener
|
|
1881
2169
|
// picks which to hear.
|
|
@@ -2841,7 +3129,18 @@ export function createServer(engine, options) {
|
|
|
2841
3129
|
}
|
|
2842
3130
|
export async function serve(argv, version = "0.1.0") {
|
|
2843
3131
|
const options = parseServeArgs(argv);
|
|
2844
|
-
|
|
3132
|
+
// Told which folder, or the one that was saved. Never the directory this
|
|
3133
|
+
// happens to be running in: a daemon restarted from a home directory served
|
|
3134
|
+
// the home directory, keys and all, under a public listing.
|
|
3135
|
+
const chosen = options.root || readLibrary();
|
|
3136
|
+
if (!chosen) {
|
|
3137
|
+
throw new Error("nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.");
|
|
3138
|
+
}
|
|
3139
|
+
const root = isRemote(chosen) ? chosen : resolve(chosen);
|
|
3140
|
+
const why = isRemote(root) ? "" : forbiddenLibrary(root);
|
|
3141
|
+
if (why) {
|
|
3142
|
+
throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
|
|
3143
|
+
}
|
|
2845
3144
|
const tools = detectTools();
|
|
2846
3145
|
// Names now, tags later.
|
|
2847
3146
|
//
|
|
@@ -2906,6 +3205,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2906
3205
|
console.log(` "${one.id}" is already on.`);
|
|
2907
3206
|
});
|
|
2908
3207
|
}
|
|
3208
|
+
// The m3u catalogs kept here: read from disk now, and any that were never
|
|
3209
|
+
// read are fetched in the background so browsing does not wait on a provider.
|
|
3210
|
+
const catalogs = new Catalogs(stateDir(), options.port);
|
|
3211
|
+
catalogs.load();
|
|
3212
|
+
void catalogs.warm().catch(() => undefined);
|
|
2909
3213
|
const destinations = parseDestinations(options.rtmp);
|
|
2910
3214
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
2911
3215
|
const ingest = options.ingest
|
|
@@ -2926,6 +3230,53 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2926
3230
|
: undefined;
|
|
2927
3231
|
const follows = pool ? new Follows(pool) : undefined;
|
|
2928
3232
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
3233
|
+
// Names and certificates for signed-in servers, and the rate limit over
|
|
3234
|
+
// everything. All of it is nixamp.com's business: the DNS keys live only
|
|
3235
|
+
// here, the certificates are issued here, and a personal nixamp has neither
|
|
3236
|
+
// a database nor strangers to meter. Without the registrar's keys the names
|
|
3237
|
+
// are simply not offered, rather than written into a zone that does not
|
|
3238
|
+
// exist.
|
|
3239
|
+
const zoneName = (() => {
|
|
3240
|
+
try {
|
|
3241
|
+
return new URL(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).hostname;
|
|
3242
|
+
}
|
|
3243
|
+
catch {
|
|
3244
|
+
return "nixamp.com";
|
|
3245
|
+
}
|
|
3246
|
+
})();
|
|
3247
|
+
const porkbunKey = process.env["PORKBUN_API_KEY"] ?? "";
|
|
3248
|
+
const porkbunSecret = process.env["PORKBUN_SECRET_API_KEY"] ?? "";
|
|
3249
|
+
const zone = porkbunKey && porkbunSecret ? new Porkbun(zoneName, porkbunKey, porkbunSecret) : null;
|
|
3250
|
+
const names = pool && zone ? new Names(pool, zone) : undefined;
|
|
3251
|
+
let certs;
|
|
3252
|
+
if (pool && zone) {
|
|
3253
|
+
const issuer = new AcmeIssuer({
|
|
3254
|
+
directoryUrl: process.env["NIXAMP_ACME_DIRECTORY"] ?? "https://acme-v02.api.letsencrypt.org/directory",
|
|
3255
|
+
email: process.env["NIXAMP_ACME_EMAIL"] ?? `hostmaster@${zoneName}`,
|
|
3256
|
+
// The key is kept by the store, so the issuer asks for it each time
|
|
3257
|
+
// rather than holding one that a second instance would not share.
|
|
3258
|
+
accountKey: () => certs.accountKey(),
|
|
3259
|
+
});
|
|
3260
|
+
certs = new Certs(pool, zone, issuer, { log: (line) => console.log(` ${line}`) });
|
|
3261
|
+
}
|
|
3262
|
+
const throttle = pool
|
|
3263
|
+
? createThrottle({
|
|
3264
|
+
rules: [
|
|
3265
|
+
// Sign-in stays address-bucketed however the request is dressed, or
|
|
3266
|
+
// a guess with an Authorization header buys itself the bigger budget.
|
|
3267
|
+
{ path: "/api/v1/auth/", limit: 20, credential: false },
|
|
3268
|
+
{ path: "/api/v1/dns/", limit: 30 },
|
|
3269
|
+
{ path: "/api/v1/dns", limit: 30 },
|
|
3270
|
+
{ path: "/api/v1/certs", limit: 30 },
|
|
3271
|
+
{ path: "/api/health", open: true },
|
|
3272
|
+
{ path: "/api/directory", limit: 120 },
|
|
3273
|
+
],
|
|
3274
|
+
// A signed-in browser carries its session as a cookie, and is a
|
|
3275
|
+
// credential the same as a bearer token: a person on a dashboard is
|
|
3276
|
+
// not an anonymous scraper.
|
|
3277
|
+
credentialFrom: (request) => presentedCredential(request.headers) ?? (tokenFrom(Object.fromEntries(request.headers)) || null),
|
|
3278
|
+
})
|
|
3279
|
+
: undefined;
|
|
2929
3280
|
// The two things that were promises kept only in memory: a caller who was
|
|
2930
3281
|
// told they would be texted, and the ended stream a code still points at.
|
|
2931
3282
|
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
@@ -3066,7 +3417,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3066
3417
|
}
|
|
3067
3418
|
// Read before listening, so a missing or unreadable certificate is a sentence
|
|
3068
3419
|
// now rather than a connection that resets later.
|
|
3069
|
-
|
|
3420
|
+
let tls = options.tlsCert
|
|
3070
3421
|
? (() => {
|
|
3071
3422
|
try {
|
|
3072
3423
|
return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
|
|
@@ -3076,6 +3427,42 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3076
3427
|
}
|
|
3077
3428
|
})()
|
|
3078
3429
|
: undefined;
|
|
3430
|
+
// A signed-in server names itself.
|
|
3431
|
+
//
|
|
3432
|
+
// Nothing about DNS or certificates reaches this machine: it asks nixamp.com
|
|
3433
|
+
// for `<label>.<handle>.<zone>` pointing at the address it is calling from,
|
|
3434
|
+
// and for the handle's wildcard certificate, and serves https under that
|
|
3435
|
+
// name. The registrar's keys stay on nixamp.com. Skipped when the operator
|
|
3436
|
+
// named or certified the server by hand, when it listens on one interface
|
|
3437
|
+
// only, or with --no-name.
|
|
3438
|
+
let certExpiresAt = 0;
|
|
3439
|
+
let namedHost = "";
|
|
3440
|
+
const namedSession = readSession();
|
|
3441
|
+
if (!options.noName && !options.publicUrl && !options.tlsCert &&
|
|
3442
|
+
(options.host === "0.0.0.0" || options.host === "::") && namedSession?.token) {
|
|
3443
|
+
const say = (line) => console.log(` ${line}`);
|
|
3444
|
+
const named = await claimName(namedSession.site, namedSession.token, labelFor(options.name, hostname()), say);
|
|
3445
|
+
if (named) {
|
|
3446
|
+
namedHost = named.host;
|
|
3447
|
+
// The certificate is the handle's, so the cache is keyed by the handle's
|
|
3448
|
+
// wildcard rather than by this machine's label.
|
|
3449
|
+
const wildcard = `*.${named.host.split(".").slice(1).join(".")}`;
|
|
3450
|
+
let files = readCertFiles(stateDir(), wildcard);
|
|
3451
|
+
if (!files) {
|
|
3452
|
+
const got = await fetchCert(namedSession.site, namedSession.token, {}, say);
|
|
3453
|
+
if (got) {
|
|
3454
|
+
writeCertFiles(stateDir(), got);
|
|
3455
|
+
files = { cert: got.cert, key: got.key, expiresAt: got.expiresAt };
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
if (files) {
|
|
3459
|
+
tls = { cert: files.cert, key: files.key };
|
|
3460
|
+
certExpiresAt = files.expiresAt;
|
|
3461
|
+
}
|
|
3462
|
+
options.publicUrl = `${tls ? "https" : "http"}://${named.host}:${options.port}`;
|
|
3463
|
+
console.log(` This server is ${named.host}${tls ? "" : " -- no certificate yet, so http for now"}.`);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3079
3466
|
// Filled in below, when the RTMP listeners are opened. Read through a
|
|
3080
3467
|
// function so the handler sees the list rather than the empty array it was
|
|
3081
3468
|
// built with.
|
|
@@ -3092,6 +3479,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3092
3479
|
owner,
|
|
3093
3480
|
channels,
|
|
3094
3481
|
rememberChannels: remembering,
|
|
3482
|
+
catalogs,
|
|
3095
3483
|
publishUrls: () => publishUrls,
|
|
3096
3484
|
serverName: options.name || hostname(),
|
|
3097
3485
|
homeSource: root,
|
|
@@ -3150,6 +3538,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3150
3538
|
...(directory ? { directory } : {}),
|
|
3151
3539
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
3152
3540
|
...(favorites ? { favorites } : {}),
|
|
3541
|
+
...(names ? { names } : {}),
|
|
3542
|
+
...(certs ? { certs } : {}),
|
|
3543
|
+
dnsZone: zoneName,
|
|
3544
|
+
...(throttle ? { throttle } : {}),
|
|
3153
3545
|
...(partyLine ? { partyLine } : {}),
|
|
3154
3546
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
3155
3547
|
// laptop has nobody to be an account of.
|
|
@@ -3192,6 +3584,23 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3192
3584
|
});
|
|
3193
3585
|
const bound = server.address();
|
|
3194
3586
|
const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
3587
|
+
// A named server keeps its certificate fresh without a restart: once a day
|
|
3588
|
+
// it asks for the handle's certificate again and, when a newer one has been
|
|
3589
|
+
// issued, swaps it into the running listener.
|
|
3590
|
+
if (namedHost && namedSession?.token) {
|
|
3591
|
+
const renew = setInterval(() => {
|
|
3592
|
+
void fetchCert(namedSession.site, namedSession.token, { waitMs: 0 }, () => undefined).then((got) => {
|
|
3593
|
+
if (!got || got.expiresAt <= certExpiresAt)
|
|
3594
|
+
return;
|
|
3595
|
+
writeCertFiles(stateDir(), got);
|
|
3596
|
+
certExpiresAt = got.expiresAt;
|
|
3597
|
+
const secure = server;
|
|
3598
|
+
secure.setSecureContext?.({ cert: got.cert, key: got.key });
|
|
3599
|
+
console.log(` Renewed the certificate for ${namedHost}.`);
|
|
3600
|
+
});
|
|
3601
|
+
}, 24 * 60 * 60 * 1000);
|
|
3602
|
+
renew.unref();
|
|
3603
|
+
}
|
|
3195
3604
|
const io = {
|
|
3196
3605
|
read: readIfPossible,
|
|
3197
3606
|
run: (command, args) => {
|
|
@@ -3397,6 +3806,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3397
3806
|
name: options.name || hostname(),
|
|
3398
3807
|
url: listen,
|
|
3399
3808
|
audio,
|
|
3809
|
+
// The control link, for the owner to open this machine as its
|
|
3810
|
+
// administrator from the directory. The directory shows it to the
|
|
3811
|
+
// owning account and strips it for everyone else.
|
|
3812
|
+
...(key ? { admin: shareLink(publishable_.url, key) } : {}),
|
|
3400
3813
|
// Asked at every heartbeat rather than once, because the library is
|
|
3401
3814
|
// read after the port opens and is still arriving when this is made.
|
|
3402
3815
|
tracks: () => engine.snapshot(false).trackCount,
|
package/dist/session.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export declare function chooseWay(ways: SiteWays, options: LoginOptions): Promis
|
|
|
95
95
|
* server keeps only its hash and has nothing to show a second time.
|
|
96
96
|
*/
|
|
97
97
|
export declare function tokens(argv: string[], fetcher?: typeof fetch): Promise<number>;
|
|
98
|
+
/**
|
|
99
|
+
* `nixamp dns list|set|rm`: names under your handle, for your servers.
|
|
100
|
+
*
|
|
101
|
+
* The DNS keys stay on nixamp.com; this only says which label should point
|
|
102
|
+
* where. "auto" is the address the request arrives from, which is what a
|
|
103
|
+
* server naming itself wants and what nobody behind a router can type.
|
|
104
|
+
*/
|
|
105
|
+
export declare function dns(argv: string[], fetcher?: typeof fetch): Promise<number>;
|
|
98
106
|
export declare function logout(): number;
|
|
99
107
|
/** `nixamp whoami`, which asks the server rather than trusting the file. */
|
|
100
108
|
export declare function whoami(fetcher?: typeof fetch): Promise<number>;
|