nixamp 0.5.11 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +21 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +115 -5
- package/dist/servers.d.ts +46 -0
- package/dist/servers.js +155 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.js +97 -0
- package/dist/share.d.ts +1 -1
- package/dist/share.js +3 -3
- package/package.json +1 -1
- package/src/main.ts +21 -0
- package/src/server.ts +141 -5
- package/src/servers.ts +189 -0
- package/src/session.ts +110 -0
- package/src/share.ts +3 -2
- package/web/dist/assets/{hls-3VKVEQE3-Bug6MFON.js → hls-3VKVEQE3-CSh9YGnu.js} +1 -1
- package/web/dist/assets/index-PgqcCBW4.js +1 -0
- package/web/dist/assets/{mpegts-BtEekTJ6.js → mpegts-DPXXHio1.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DW1kEon1.js → mpegts-LO6RVLD6-BybaRBHq.js} +1 -1
- package/web/dist/index.html +6 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CRfW9BIP.js +0 -1
package/dist/main.js
CHANGED
|
@@ -51,6 +51,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
51
51
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
52
52
|
nixamp logout / whoami forget it, or check it
|
|
53
53
|
nixamp token create|list|revoke tokens for a machine that cannot sign in
|
|
54
|
+
nixamp server list|add|remove the machines you run, kept against your account
|
|
54
55
|
nixamp update [version] re-run the installer, keeping your choices
|
|
55
56
|
nixamp uninstall [--yes] remove everything the installer created
|
|
56
57
|
|
|
@@ -72,6 +73,9 @@ Options for serve:
|
|
|
72
73
|
its own interfaces. Also NIXAMP_PUBLIC_URL
|
|
73
74
|
--no-lookup do not ask ipinfo.io what this machine's public address is
|
|
74
75
|
when nothing local looks public
|
|
76
|
+
--tls-cert FILE --tls-key FILE serve https rather than http. Needed by
|
|
77
|
+
anyone opening this from a page that is itself https, since
|
|
78
|
+
a browser refuses every request from https to http
|
|
75
79
|
--ingest accept a live stream in at POST /api/ingest
|
|
76
80
|
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
77
81
|
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
@@ -167,6 +171,18 @@ keeps serving its browser remote. One per user.
|
|
|
167
171
|
nixamp admin who is connected, and re-stream to them
|
|
168
172
|
|
|
169
173
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
174
|
+
`,
|
|
175
|
+
server: `nixamp server -- the machines you run.
|
|
176
|
+
|
|
177
|
+
nixamp server list every server on your account
|
|
178
|
+
nixamp server add --here remember the daemon on this machine
|
|
179
|
+
nixamp server add URL --name x remember one somewhere else
|
|
180
|
+
nixamp server remove ID forget it
|
|
181
|
+
|
|
182
|
+
A share link printed in a terminal you have since closed is a server you have
|
|
183
|
+
lost. This keeps the address against your account, so the answer is the same
|
|
184
|
+
here, in the browser and in the desktop app. The share key is kept with it only
|
|
185
|
+
if you pass one, since it is the secret that opens the machine.
|
|
170
186
|
`,
|
|
171
187
|
attach: `nixamp attach — the player, in front of the running daemon.
|
|
172
188
|
|
|
@@ -276,6 +292,11 @@ export async function main() {
|
|
|
276
292
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
277
293
|
return;
|
|
278
294
|
}
|
|
295
|
+
if (first === "server" || first === "servers") {
|
|
296
|
+
const { servers } = await import("./session.js");
|
|
297
|
+
process.exitCode = await servers(rest);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
279
300
|
if (first === "token" || first === "tokens") {
|
|
280
301
|
const { tokens } = await import("./session.js");
|
|
281
302
|
process.exitCode = await tokens(rest);
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
|
|
|
4
4
|
import { Ingest } from "./ingest.ts";
|
|
5
5
|
import { Channels } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
|
+
import { Servers } from "./servers.ts";
|
|
7
8
|
import { SignIn } from "./oauth.ts";
|
|
8
9
|
import { Owner } from "./owner.ts";
|
|
9
10
|
import { Directory } from "./directory.ts";
|
|
@@ -56,6 +57,18 @@ export interface ServeOptions {
|
|
|
56
57
|
* listing is skipped and the printed links only work inside the house.
|
|
57
58
|
*/
|
|
58
59
|
publicUrl: string;
|
|
60
|
+
/**
|
|
61
|
+
* A certificate and its key, to serve https rather than http.
|
|
62
|
+
*
|
|
63
|
+
* Needed by anybody whose nixamp is opened from a page that is itself https:
|
|
64
|
+
* a browser refuses every request from an https page to an http one --
|
|
65
|
+
* fetch, event stream and media alike -- and no header on either side lifts
|
|
66
|
+
* that. It is deliberately not required: a nixamp on 192.168.1.5 cannot have
|
|
67
|
+
* a certificate for that address, and forcing one would put a browser
|
|
68
|
+
* warning in front of everybody at home to fix a problem they do not have.
|
|
69
|
+
*/
|
|
70
|
+
tlsCert: string;
|
|
71
|
+
tlsKey: string;
|
|
59
72
|
/**
|
|
60
73
|
* Ask an outside service what this machine's public address is, when no
|
|
61
74
|
* interface holds one and none was given. Behind NAT that is the only way to
|
|
@@ -248,8 +261,15 @@ export interface HandlerOptions {
|
|
|
248
261
|
accounts?: Accounts;
|
|
249
262
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
|
250
263
|
signIn?: SignIn;
|
|
264
|
+
/** The servers each account runs, on the instance that keeps accounts. */
|
|
265
|
+
servers?: Servers;
|
|
251
266
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
252
267
|
secureCookies?: boolean;
|
|
268
|
+
/** A certificate and key in PEM, when this server is to speak https itself. */
|
|
269
|
+
tls?: {
|
|
270
|
+
cert: string;
|
|
271
|
+
key: string;
|
|
272
|
+
};
|
|
253
273
|
/**
|
|
254
274
|
* True when a proxy sits in front, so `x-forwarded-for` names the caller.
|
|
255
275
|
* False everywhere else on purpose: the header is trivially forged, and
|
package/dist/server.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createReadStream, statSync } from "node:fs";
|
|
13
13
|
import { createServer as createHttpServer } from "node:http";
|
|
14
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
14
15
|
import { hostname } from "node:os";
|
|
15
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
16
17
|
import { readFileSync } from "node:fs";
|
|
@@ -20,6 +21,7 @@ import { Ingest, normaliseFormat } from "./ingest.js";
|
|
|
20
21
|
import { Channels, cleanId } from "./channels.js";
|
|
21
22
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
22
23
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
24
|
+
import { Servers } from "./servers.js";
|
|
23
25
|
import { DeviceGrants } from "./device.js";
|
|
24
26
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
25
27
|
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
@@ -70,6 +72,8 @@ export function parseServeArgs(argv) {
|
|
|
70
72
|
name: "",
|
|
71
73
|
publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
|
|
72
74
|
lookup: true,
|
|
75
|
+
tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
|
|
76
|
+
tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
|
|
73
77
|
x402: false,
|
|
74
78
|
owner: "",
|
|
75
79
|
ingest: false,
|
|
@@ -78,6 +82,11 @@ export function parseServeArgs(argv) {
|
|
|
78
82
|
rtmp: [],
|
|
79
83
|
};
|
|
80
84
|
let sawRoot = false;
|
|
85
|
+
const bothOrNeither = () => {
|
|
86
|
+
if (Boolean(options.tlsCert) !== Boolean(options.tlsKey)) {
|
|
87
|
+
throw new Error("nixamp serve: --tls-cert and --tls-key go together");
|
|
88
|
+
}
|
|
89
|
+
};
|
|
81
90
|
for (let i = 0; i < argv.length; i++) {
|
|
82
91
|
const arg = argv[i];
|
|
83
92
|
const value = () => {
|
|
@@ -130,6 +139,12 @@ export function parseServeArgs(argv) {
|
|
|
130
139
|
}
|
|
131
140
|
options.publicUrl = given.replace(/\/+$/, "");
|
|
132
141
|
}
|
|
142
|
+
else if (arg === "--tls-cert") {
|
|
143
|
+
options.tlsCert = value();
|
|
144
|
+
}
|
|
145
|
+
else if (arg === "--tls-key") {
|
|
146
|
+
options.tlsKey = value();
|
|
147
|
+
}
|
|
133
148
|
else if (arg === "--no-lookup") {
|
|
134
149
|
options.lookup = false;
|
|
135
150
|
}
|
|
@@ -175,6 +190,7 @@ export function parseServeArgs(argv) {
|
|
|
175
190
|
sawRoot = true;
|
|
176
191
|
}
|
|
177
192
|
}
|
|
193
|
+
bothOrNeither();
|
|
178
194
|
return options;
|
|
179
195
|
}
|
|
180
196
|
const TYPES = {
|
|
@@ -534,7 +550,12 @@ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
|
534
550
|
* the share-key check rather than behind it.
|
|
535
551
|
*/
|
|
536
552
|
export function isSignInPath(path) {
|
|
537
|
-
return path.startsWith("/api/v1/auth/") ||
|
|
553
|
+
return (path.startsWith("/api/v1/auth/") ||
|
|
554
|
+
// The account's own server list: nixamp.com's API, gated by the session
|
|
555
|
+
// rather than by a share key it has nothing to do with.
|
|
556
|
+
path === "/api/v1/servers" ||
|
|
557
|
+
path.startsWith("/api/v1/servers/") ||
|
|
558
|
+
OAUTH_ROUTE.test(path));
|
|
538
559
|
}
|
|
539
560
|
function html(response, code, body) {
|
|
540
561
|
response.writeHead(code, {
|
|
@@ -973,6 +994,73 @@ export function createHandler(engine, options) {
|
|
|
973
994
|
json(response, 404, { error: "no such endpoint" });
|
|
974
995
|
return;
|
|
975
996
|
}
|
|
997
|
+
// --- the servers this account runs ----------------------------------
|
|
998
|
+
//
|
|
999
|
+
// Kept against the account rather than the machine, so the list reads the
|
|
1000
|
+
// same from the CLI, the PWA and the desktop app -- which is the whole
|
|
1001
|
+
// point: a share link in a terminal you closed is a server you have lost.
|
|
1002
|
+
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
|
|
1003
|
+
const servers = options.servers;
|
|
1004
|
+
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
1005
|
+
if (who === null) {
|
|
1006
|
+
json(response, 401, { error: "not signed in" });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
1010
|
+
json(response, 200, { servers: await servers.list(who.id) });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (path === "/api/v1/servers" && request.method === "POST") {
|
|
1014
|
+
let body;
|
|
1015
|
+
try {
|
|
1016
|
+
body = JSON.parse(await readBody(request));
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
json(response, 400, { error: "bad JSON" });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const made = await servers.add(who, {
|
|
1023
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1024
|
+
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1025
|
+
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1026
|
+
});
|
|
1027
|
+
if (made === null) {
|
|
1028
|
+
json(response, 422, { error: "that needs an http or https address" });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
json(response, 201, { server: made });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const id = path.slice("/api/v1/servers/".length);
|
|
1035
|
+
if (id && request.method === "DELETE") {
|
|
1036
|
+
const gone = await servers.remove(who.id, id);
|
|
1037
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (id && (request.method === "PATCH" || request.method === "PUT")) {
|
|
1041
|
+
let body;
|
|
1042
|
+
try {
|
|
1043
|
+
body = JSON.parse(await readBody(request));
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
json(response, 400, { error: "bad JSON" });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const changed = await servers.update(who.id, id, {
|
|
1050
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1051
|
+
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1052
|
+
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1053
|
+
});
|
|
1054
|
+
if (changed === null) {
|
|
1055
|
+
json(response, 404, { error: "no such server, or a bad address" });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
json(response, 200, { server: changed });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
json(response, 405, { error: "GET, POST, PATCH or DELETE" });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
976
1064
|
// --- tokens a person made on purpose --------------------------------
|
|
977
1065
|
if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
|
|
978
1066
|
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -1898,14 +1986,20 @@ function sendFile(request, response, file) {
|
|
|
1898
1986
|
}
|
|
1899
1987
|
export function createServer(engine, options) {
|
|
1900
1988
|
const handle = createHandler(engine, options);
|
|
1901
|
-
|
|
1989
|
+
const onRequest = (request, response) => {
|
|
1902
1990
|
handle(request, response).catch(() => {
|
|
1903
1991
|
if (!response.headersSent)
|
|
1904
1992
|
json(response, 500, { error: "server error" });
|
|
1905
1993
|
else
|
|
1906
1994
|
response.end();
|
|
1907
1995
|
});
|
|
1908
|
-
}
|
|
1996
|
+
};
|
|
1997
|
+
// https when there is a certificate to serve it with, and the same handler
|
|
1998
|
+
// either way: nothing above this line knows or cares which it got.
|
|
1999
|
+
if (options.tls) {
|
|
2000
|
+
return createHttpsServer({ cert: options.tls.cert, key: options.tls.key }, onRequest);
|
|
2001
|
+
}
|
|
2002
|
+
return createHttpServer(onRequest);
|
|
1909
2003
|
}
|
|
1910
2004
|
export async function serve(argv, version = "0.1.0") {
|
|
1911
2005
|
const options = parseServeArgs(argv);
|
|
@@ -2112,6 +2206,18 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2112
2206
|
});
|
|
2113
2207
|
});
|
|
2114
2208
|
}
|
|
2209
|
+
// Read before listening, so a missing or unreadable certificate is a sentence
|
|
2210
|
+
// now rather than a connection that resets later.
|
|
2211
|
+
const tls = options.tlsCert
|
|
2212
|
+
? (() => {
|
|
2213
|
+
try {
|
|
2214
|
+
return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
|
|
2215
|
+
}
|
|
2216
|
+
catch (error) {
|
|
2217
|
+
throw new Error(`nixamp serve: could not read the certificate: ${error.message}`);
|
|
2218
|
+
}
|
|
2219
|
+
})()
|
|
2220
|
+
: undefined;
|
|
2115
2221
|
const server = createServer(engine, {
|
|
2116
2222
|
web,
|
|
2117
2223
|
media: options.media,
|
|
@@ -2127,6 +2233,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2127
2233
|
paywall,
|
|
2128
2234
|
ffmpeg: tools.ffmpeg,
|
|
2129
2235
|
ffprobe: tools.ffprobe,
|
|
2236
|
+
...(tls ? { tls } : {}),
|
|
2130
2237
|
load: (next) => loadSource(tools, next),
|
|
2131
2238
|
...(directory ? { directory } : {}),
|
|
2132
2239
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
@@ -2148,6 +2255,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2148
2255
|
// Whichever providers this deployment was given both halves of, plus
|
|
2149
2256
|
// the device grant, which is worth having even with no provider at
|
|
2150
2257
|
// all: a browser already signed in can approve a terminal.
|
|
2258
|
+
// The same pool the follows and reminders use: three small tables in
|
|
2259
|
+
// one database do not want three sets of connections.
|
|
2260
|
+
...(pool ? { servers: new Servers(pool) } : {}),
|
|
2151
2261
|
signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
|
|
2152
2262
|
}
|
|
2153
2263
|
: {}),
|
|
@@ -2181,12 +2291,12 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2181
2291
|
// is and nothing local looks public, ask. What comes back is a fact about the
|
|
2182
2292
|
// router and not about this port -- the port still has to be forwarded -- so
|
|
2183
2293
|
// it is marked as a guess and everything that prints it says so.
|
|
2184
|
-
const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2294
|
+
const localAddresses = reachableAddresses(options.host, port, options.publicUrl, tls ? "https" : "http");
|
|
2185
2295
|
const guessedPublic = options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
|
|
2186
2296
|
? await lookupPublicIp()
|
|
2187
2297
|
: "";
|
|
2188
2298
|
const addresses = guessedPublic
|
|
2189
|
-
? reachableAddresses(options.host, port,
|
|
2299
|
+
? reachableAddresses(options.host, port, `${tls ? "https" : "http"}://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`, tls ? "https" : "http")
|
|
2190
2300
|
: localAddresses;
|
|
2191
2301
|
// Listening on every interface proves the socket is open here and nothing
|
|
2192
2302
|
// about the path between here and the phone.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Account } from "./accounts.ts";
|
|
2
|
+
import type { Queryable } from "./follows.ts";
|
|
3
|
+
export interface ServerEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
url: string;
|
|
7
|
+
/** Empty when the owner chose not to keep it here. */
|
|
8
|
+
key: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
lastSeenAt: number | null;
|
|
12
|
+
}
|
|
13
|
+
/** What a caller may set. Anything else is the server's business. */
|
|
14
|
+
export interface ServerInput {
|
|
15
|
+
name?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
key?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* An address a browser could open, with nothing else smuggled in.
|
|
21
|
+
*
|
|
22
|
+
* A stored URL is handed back to other clients and put in an href, so a
|
|
23
|
+
* javascript: or data: entry here is a link somebody else clicks. Only http
|
|
24
|
+
* and https, and nothing after the origin: a nixamp is a host and a port.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cleanUrl(value: unknown): string;
|
|
27
|
+
/** A name is a label, not an essay, and never empty on screen. */
|
|
28
|
+
export declare function cleanName(value: unknown, url: string): string;
|
|
29
|
+
export declare class Servers {
|
|
30
|
+
private readonly db;
|
|
31
|
+
private ready;
|
|
32
|
+
constructor(db: Queryable);
|
|
33
|
+
private ensure;
|
|
34
|
+
list(userId: string): Promise<ServerEntry[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Remember a server, or update the one already at that address.
|
|
37
|
+
*
|
|
38
|
+
* Upserted on the address rather than refused, because the common way to
|
|
39
|
+
* call this twice is a daemon announcing itself after a restart, and that
|
|
40
|
+
* should move the entry forward rather than fail.
|
|
41
|
+
*/
|
|
42
|
+
add(account: Account, input: ServerInput): Promise<ServerEntry | null>;
|
|
43
|
+
/** Scoped to the owner, so somebody else's id changes nothing. */
|
|
44
|
+
update(userId: string, id: string, input: ServerInput): Promise<ServerEntry | null>;
|
|
45
|
+
remove(userId: string, id: string): Promise<boolean>;
|
|
46
|
+
}
|
package/dist/servers.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers a person runs.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp is a machine somewhere with an address and a key, and until now the
|
|
5
|
+
* only record of one was the share link in whatever terminal printed it. Lose
|
|
6
|
+
* the terminal and you have lost the server: the daemon is still playing, and
|
|
7
|
+
* nothing anywhere can tell you where. This is the list, kept against the
|
|
8
|
+
* account rather than the machine, so it reads the same from the CLI, the PWA
|
|
9
|
+
* and the desktop app.
|
|
10
|
+
*
|
|
11
|
+
* The share key is optional and, when given, is the listen-or-control secret
|
|
12
|
+
* for somebody else's machine sitting in our database. It is stored because a
|
|
13
|
+
* list you cannot click is half a feature, and it is optional because that is
|
|
14
|
+
* a choice which belongs to the person whose server it is.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
const TABLE = "nixamp_servers";
|
|
18
|
+
const SCHEMA = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
user_id TEXT NOT NULL,
|
|
22
|
+
name TEXT NOT NULL DEFAULT '',
|
|
23
|
+
url TEXT NOT NULL,
|
|
24
|
+
share_key TEXT NOT NULL DEFAULT '',
|
|
25
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
26
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
27
|
+
last_seen_at TIMESTAMPTZ
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
|
|
30
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_user_url ON ${TABLE} (user_id, url);
|
|
31
|
+
`;
|
|
32
|
+
function asTime(value) {
|
|
33
|
+
if (value instanceof Date)
|
|
34
|
+
return value.getTime();
|
|
35
|
+
if (typeof value === "string") {
|
|
36
|
+
const at = Date.parse(value);
|
|
37
|
+
return Number.isNaN(at) ? null : at;
|
|
38
|
+
}
|
|
39
|
+
return typeof value === "number" ? value : null;
|
|
40
|
+
}
|
|
41
|
+
function toEntry(row) {
|
|
42
|
+
return {
|
|
43
|
+
id: String(row["id"] ?? ""),
|
|
44
|
+
name: String(row["name"] ?? ""),
|
|
45
|
+
url: String(row["url"] ?? ""),
|
|
46
|
+
key: String(row["share_key"] ?? ""),
|
|
47
|
+
createdAt: asTime(row["created_at"]) ?? 0,
|
|
48
|
+
updatedAt: asTime(row["updated_at"]) ?? 0,
|
|
49
|
+
lastSeenAt: asTime(row["last_seen_at"]),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* An address a browser could open, with nothing else smuggled in.
|
|
54
|
+
*
|
|
55
|
+
* A stored URL is handed back to other clients and put in an href, so a
|
|
56
|
+
* javascript: or data: entry here is a link somebody else clicks. Only http
|
|
57
|
+
* and https, and nothing after the origin: a nixamp is a host and a port.
|
|
58
|
+
*/
|
|
59
|
+
export function cleanUrl(value) {
|
|
60
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
61
|
+
return "";
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(value.trim());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
70
|
+
return "";
|
|
71
|
+
return parsed.origin;
|
|
72
|
+
}
|
|
73
|
+
/** A name is a label, not an essay, and never empty on screen. */
|
|
74
|
+
export function cleanName(value, url) {
|
|
75
|
+
const given = typeof value === "string" ? value.trim().slice(0, 60) : "";
|
|
76
|
+
if (given)
|
|
77
|
+
return given;
|
|
78
|
+
// The host is a better default than "untitled", and it is what somebody
|
|
79
|
+
// would have typed anyway.
|
|
80
|
+
try {
|
|
81
|
+
return new URL(url).host;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return "a nixamp";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class Servers {
|
|
88
|
+
db;
|
|
89
|
+
ready = null;
|
|
90
|
+
constructor(db) {
|
|
91
|
+
this.db = db;
|
|
92
|
+
}
|
|
93
|
+
async ensure() {
|
|
94
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
95
|
+
await this.ready;
|
|
96
|
+
}
|
|
97
|
+
async list(userId) {
|
|
98
|
+
await this.ensure();
|
|
99
|
+
const { rows } = await this.db.query(`SELECT id, name, url, share_key, created_at, updated_at, last_seen_at FROM ${TABLE}
|
|
100
|
+
WHERE user_id = $1 ORDER BY created_at`, [userId]);
|
|
101
|
+
return rows.map(toEntry);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Remember a server, or update the one already at that address.
|
|
105
|
+
*
|
|
106
|
+
* Upserted on the address rather than refused, because the common way to
|
|
107
|
+
* call this twice is a daemon announcing itself after a restart, and that
|
|
108
|
+
* should move the entry forward rather than fail.
|
|
109
|
+
*/
|
|
110
|
+
async add(account, input) {
|
|
111
|
+
const url = cleanUrl(input.url);
|
|
112
|
+
if (!url)
|
|
113
|
+
return null;
|
|
114
|
+
await this.ensure();
|
|
115
|
+
const { rows } = await this.db.query(`INSERT INTO ${TABLE} (id, user_id, name, url, share_key, last_seen_at)
|
|
116
|
+
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
117
|
+
ON CONFLICT (user_id, url) DO UPDATE
|
|
118
|
+
SET name = EXCLUDED.name, share_key = EXCLUDED.share_key,
|
|
119
|
+
updated_at = NOW(), last_seen_at = NOW()
|
|
120
|
+
RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`, [
|
|
121
|
+
randomBytes(8).toString("hex"),
|
|
122
|
+
account.id,
|
|
123
|
+
cleanName(input.name, url),
|
|
124
|
+
url,
|
|
125
|
+
typeof input.key === "string" ? input.key.slice(0, 200) : "",
|
|
126
|
+
]);
|
|
127
|
+
return rows[0] ? toEntry(rows[0]) : null;
|
|
128
|
+
}
|
|
129
|
+
/** Scoped to the owner, so somebody else's id changes nothing. */
|
|
130
|
+
async update(userId, id, input) {
|
|
131
|
+
await this.ensure();
|
|
132
|
+
const url = input.url === undefined ? undefined : cleanUrl(input.url);
|
|
133
|
+
if (input.url !== undefined && !url)
|
|
134
|
+
return null;
|
|
135
|
+
const { rows } = await this.db.query(`UPDATE ${TABLE}
|
|
136
|
+
SET name = COALESCE($3, name),
|
|
137
|
+
url = COALESCE($4, url),
|
|
138
|
+
share_key = COALESCE($5, share_key),
|
|
139
|
+
updated_at = NOW()
|
|
140
|
+
WHERE user_id = $1 AND id = $2
|
|
141
|
+
RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`, [
|
|
142
|
+
userId,
|
|
143
|
+
id,
|
|
144
|
+
input.name === undefined ? null : cleanName(input.name, url ?? ""),
|
|
145
|
+
url ?? null,
|
|
146
|
+
input.key === undefined ? null : input.key.slice(0, 200),
|
|
147
|
+
]);
|
|
148
|
+
return rows[0] ? toEntry(rows[0]) : null;
|
|
149
|
+
}
|
|
150
|
+
async remove(userId, id) {
|
|
151
|
+
await this.ensure();
|
|
152
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`, [userId, id]);
|
|
153
|
+
return rows.length > 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -98,3 +98,12 @@ export declare function tokens(argv: string[], fetcher?: typeof fetch): Promise<
|
|
|
98
98
|
export declare function logout(): number;
|
|
99
99
|
/** `nixamp whoami`, which asks the server rather than trusting the file. */
|
|
100
100
|
export declare function whoami(fetcher?: typeof fetch): Promise<number>;
|
|
101
|
+
/**
|
|
102
|
+
* `nixamp server list|add|remove`, the account's own list of machines.
|
|
103
|
+
*
|
|
104
|
+
* A share link printed in a terminal you have since closed is a server you
|
|
105
|
+
* have lost: the daemon is still playing and nothing can tell you where. This
|
|
106
|
+
* keeps the address against the account, so the answer is the same here, in
|
|
107
|
+
* the browser and in the desktop app.
|
|
108
|
+
*/
|
|
109
|
+
export declare function servers(argv: string[], fetcher?: typeof fetch): Promise<number>;
|
package/dist/session.js
CHANGED
|
@@ -481,3 +481,100 @@ export async function whoami(fetcher = fetch) {
|
|
|
481
481
|
return 0;
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* `nixamp server list|add|remove`, the account's own list of machines.
|
|
486
|
+
*
|
|
487
|
+
* A share link printed in a terminal you have since closed is a server you
|
|
488
|
+
* have lost: the daemon is still playing and nothing can tell you where. This
|
|
489
|
+
* keeps the address against the account, so the answer is the same here, in
|
|
490
|
+
* the browser and in the desktop app.
|
|
491
|
+
*/
|
|
492
|
+
export async function servers(argv, fetcher = fetch) {
|
|
493
|
+
const session = readSession();
|
|
494
|
+
if (session === null) {
|
|
495
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
const [command = "list", ...rest] = argv;
|
|
499
|
+
const where = `${session.site}/api/v1/servers`;
|
|
500
|
+
const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
|
|
501
|
+
const at = (flag) => {
|
|
502
|
+
const index = rest.indexOf(flag);
|
|
503
|
+
return index === -1 ? undefined : rest[index + 1];
|
|
504
|
+
};
|
|
505
|
+
try {
|
|
506
|
+
if (command === "add" || command === "register") {
|
|
507
|
+
// `--here` is the common case: the daemon on this machine, with the
|
|
508
|
+
// address and key it already printed, rather than retyped by hand.
|
|
509
|
+
let url = rest.find((a) => /^https?:\/\//.test(a)) ?? at("--url") ?? "";
|
|
510
|
+
let key = at("--key") ?? "";
|
|
511
|
+
if (rest.includes("--here")) {
|
|
512
|
+
const daemon = await import("./daemon.js");
|
|
513
|
+
const state = daemon.readState();
|
|
514
|
+
if (state === null) {
|
|
515
|
+
console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
// The address worth remembering is the one somebody else can open.
|
|
519
|
+
const reachable = state.urls?.find((u) => u.label === "on the internet") ?? state.urls?.[0];
|
|
520
|
+
url = reachable?.url ?? "";
|
|
521
|
+
key = key || (state.key ?? "");
|
|
522
|
+
}
|
|
523
|
+
if (!url) {
|
|
524
|
+
console.error("nixamp: which server? Give a URL, or --here for the daemon on this machine.");
|
|
525
|
+
return 64;
|
|
526
|
+
}
|
|
527
|
+
const answer = await fetcher(where, {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers,
|
|
530
|
+
body: JSON.stringify({ url, name: at("--name") ?? "", ...(key ? { key } : {}) }),
|
|
531
|
+
});
|
|
532
|
+
const body = (await answer.json().catch(() => ({})));
|
|
533
|
+
if (!answer.ok || !body.server) {
|
|
534
|
+
console.error(`nixamp: ${body.error ?? `could not add it (${answer.status})`}`);
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
console.log(`${body.server.id} ${body.server.name} ${body.server.url}`);
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
if (command === "remove" || command === "rm" || command === "forget") {
|
|
541
|
+
const id = rest.find((a) => !a.startsWith("-")) ?? "";
|
|
542
|
+
if (!id) {
|
|
543
|
+
console.error("nixamp: which one? `nixamp server list` shows their ids.");
|
|
544
|
+
return 64;
|
|
545
|
+
}
|
|
546
|
+
const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
|
|
547
|
+
if (!answer.ok) {
|
|
548
|
+
console.error(`nixamp: ${answer.status === 404 ? "no server with that id" : "could not remove it"}`);
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
console.log(`Forgot ${id}.`);
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
if (command === "list" || command === "ls") {
|
|
555
|
+
const answer = await fetcher(where, { headers });
|
|
556
|
+
const body = (await answer.json().catch(() => ({})));
|
|
557
|
+
if (!answer.ok) {
|
|
558
|
+
console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
561
|
+
const rows = body.servers ?? [];
|
|
562
|
+
if (rows.length === 0) {
|
|
563
|
+
console.log("No servers yet. `nixamp server add --here` remembers the one on this machine.");
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
const width = Math.max(...rows.map((row) => row.name.length));
|
|
567
|
+
for (const row of rows) {
|
|
568
|
+
const link = row.key ? `${row.url}/s/${row.key}` : row.url;
|
|
569
|
+
console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
|
|
570
|
+
}
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
console.error(`nixamp: no such server command: ${command}`);
|
|
574
|
+
return 64;
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
console.error(`nixamp: could not reach ${session.site}: ${error.message}`);
|
|
578
|
+
return 69;
|
|
579
|
+
}
|
|
580
|
+
}
|
package/dist/share.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number):
|
|
|
45
45
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
46
46
|
* the link is then the only thing between a stranger and the library.
|
|
47
47
|
*/
|
|
48
|
-
export declare function reachableAddresses(host: string, port: number, publicUrl?: string): {
|
|
48
|
+
export declare function reachableAddresses(host: string, port: number, publicUrl?: string, scheme?: "http" | "https"): {
|
|
49
49
|
label: string;
|
|
50
50
|
url: string;
|
|
51
51
|
}[];
|
package/dist/share.js
CHANGED
|
@@ -115,11 +115,11 @@ export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
|
|
|
115
115
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
116
116
|
* the link is then the only thing between a stranger and the library.
|
|
117
117
|
*/
|
|
118
|
-
export function reachableAddresses(host, port, publicUrl = "") {
|
|
118
|
+
export function reachableAddresses(host, port, publicUrl = "", scheme = "http") {
|
|
119
119
|
const link = (address) => {
|
|
120
120
|
// A bare IPv6 address needs brackets before it is a URL.
|
|
121
121
|
const authority = address.includes(":") ? `[${address}]` : address;
|
|
122
|
-
return
|
|
122
|
+
return `${scheme}://${authority}:${port}`;
|
|
123
123
|
};
|
|
124
124
|
// An address somebody told us about, because it is one this machine cannot
|
|
125
125
|
// know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
|
|
@@ -145,7 +145,7 @@ export function reachableAddresses(host, port, publicUrl = "") {
|
|
|
145
145
|
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
146
146
|
return [
|
|
147
147
|
...told,
|
|
148
|
-
{ label: "here", url:
|
|
148
|
+
{ label: "here", url: `${scheme}://localhost:${port}` },
|
|
149
149
|
...found.map(({ label, url }) => ({ label, url })),
|
|
150
150
|
];
|
|
151
151
|
}
|