nixamp 0.6.4 → 0.7.1
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/handles.d.ts +58 -0
- package/dist/handles.js +117 -0
- package/dist/main.js +6 -0
- package/dist/opendirs.d.ts +43 -0
- package/dist/opendirs.js +133 -0
- package/dist/playlist.d.ts +13 -0
- package/dist/playlist.js +75 -3
- package/dist/server.d.ts +6 -0
- package/dist/server.js +126 -2
- package/dist/session.d.ts +8 -0
- package/dist/session.js +86 -0
- package/package.json +1 -1
- package/src/handles.ts +141 -0
- package/src/main.ts +6 -0
- package/src/opendirs.ts +158 -0
- package/src/playlist.ts +70 -3
- package/src/server.ts +137 -2
- package/src/session.ts +100 -0
- package/web/dist/index.html +1 -0
- package/web/dist/sw.js +1 -1
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { createReadStream, statSync } from "node:fs";
|
|
13
13
|
import { createServer as createHttpServer } from "node:http";
|
|
14
14
|
import { createServer as createHttpsServer } from "node:https";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
15
16
|
import { hostname } from "node:os";
|
|
16
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
18
|
import { readFileSync } from "node:fs";
|
|
@@ -21,6 +22,8 @@ import { Ingest, normaliseFormat } from "./ingest.js";
|
|
|
21
22
|
import { Channels, cleanId } from "./channels.js";
|
|
22
23
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
23
24
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
25
|
+
import { anonymousHandle, Handles } from "./handles.js";
|
|
26
|
+
import { OpenDirs } from "./opendirs.js";
|
|
24
27
|
import { Servers } from "./servers.js";
|
|
25
28
|
import { DeviceGrants } from "./device.js";
|
|
26
29
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
@@ -43,7 +46,7 @@ import { extname, join, normalize, resolve, sep } from "node:path";
|
|
|
43
46
|
import { fileURLToPath } from "node:url";
|
|
44
47
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
45
48
|
import { Analyser, bandEdges, bands, decay } from "./fft.js";
|
|
46
|
-
import { loadSource, loadTagged } from "./playlist.js";
|
|
49
|
+
import { loadSource, loadTagged, readRemoteIndex } from "./playlist.js";
|
|
47
50
|
import { emptySnapshot, parseCommand, } from "./protocol.js";
|
|
48
51
|
const FFT_SIZE = 2048;
|
|
49
52
|
export const SERVE_BAND_COUNT = 24;
|
|
@@ -555,6 +558,10 @@ export function isSignInPath(path) {
|
|
|
555
558
|
// rather than by a share key it has nothing to do with.
|
|
556
559
|
path === "/api/v1/servers" ||
|
|
557
560
|
path.startsWith("/api/v1/servers/") ||
|
|
561
|
+
path === "/api/v1/me/handle" ||
|
|
562
|
+
// Public to read, so it must not be behind a share key either.
|
|
563
|
+
path === "/api/v1/opendirs" ||
|
|
564
|
+
path.startsWith("/api/v1/opendirs/") ||
|
|
558
565
|
OAUTH_ROUTE.test(path));
|
|
559
566
|
}
|
|
560
567
|
function html(response, code, body) {
|
|
@@ -994,6 +1001,42 @@ export function createHandler(engine, options) {
|
|
|
994
1001
|
json(response, 404, { error: "no such endpoint" });
|
|
995
1002
|
return;
|
|
996
1003
|
}
|
|
1004
|
+
// --- the name other people see ---------------------------------------
|
|
1005
|
+
//
|
|
1006
|
+
// Separate from the address on purpose. The address is a credential and
|
|
1007
|
+
// a way to reach somebody; publishing it in a directory listing or an
|
|
1008
|
+
// invite would be publishing what they log in with.
|
|
1009
|
+
if (path === "/api/v1/me/handle" && options.handles) {
|
|
1010
|
+
const handles = options.handles;
|
|
1011
|
+
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
1012
|
+
if (who === null) {
|
|
1013
|
+
json(response, 401, { error: "not signed in" });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (request.method === "GET") {
|
|
1017
|
+
json(response, 200, { handle: await handles.of(who.id) });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
1021
|
+
let body;
|
|
1022
|
+
try {
|
|
1023
|
+
body = JSON.parse(await readBody(request));
|
|
1024
|
+
}
|
|
1025
|
+
catch {
|
|
1026
|
+
json(response, 400, { error: "bad JSON" });
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const claimed = await handles.claim(who.id, body.handle);
|
|
1030
|
+
if (claimed.error) {
|
|
1031
|
+
json(response, 409, { error: claimed.error });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
json(response, 200, { handle: claimed.handle });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
json(response, 405, { error: "GET or PUT" });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
997
1040
|
// --- the servers this account runs ----------------------------------
|
|
998
1041
|
//
|
|
999
1042
|
// Kept against the account rather than the machine, so the list reads the
|
|
@@ -1140,6 +1183,17 @@ export function createHandler(engine, options) {
|
|
|
1140
1183
|
const result = signingUp
|
|
1141
1184
|
? await accounts.signUp(body.email, body.password)
|
|
1142
1185
|
: await accounts.signIn(body.email, body.password);
|
|
1186
|
+
// A handle asked for at sign-up, or one nobody has to think about. Never
|
|
1187
|
+
// derived from the address: turning anthony@… into "anthony" is the leak
|
|
1188
|
+
// this whole idea exists to avoid, and it is one nobody would notice
|
|
1189
|
+
// until it was already in a directory listing.
|
|
1190
|
+
if (signingUp && result.ok && result.account && options.handles) {
|
|
1191
|
+
const asked = body.handle;
|
|
1192
|
+
const claimed = await options.handles.claim(result.account.id, asked);
|
|
1193
|
+
if (claimed.error) {
|
|
1194
|
+
await options.handles.claim(result.account.id, anonymousHandle((size) => randomBytes(size)));
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1143
1197
|
// Getting it right costs nothing: the counters only exist to stop people
|
|
1144
1198
|
// who keep getting it wrong.
|
|
1145
1199
|
if (result.ok)
|
|
@@ -1166,6 +1220,74 @@ export function createHandler(engine, options) {
|
|
|
1166
1220
|
response.end(JSON.stringify({ account: result.account, token }));
|
|
1167
1221
|
return;
|
|
1168
1222
|
}
|
|
1223
|
+
// --- open directories somebody found -----------------------------------
|
|
1224
|
+
//
|
|
1225
|
+
// Its own list, not the stream directory: that one is what is playing now,
|
|
1226
|
+
// with a heartbeat and a room code and somebody at the other end, and this
|
|
1227
|
+
// one is a folder on the web that is always there and belongs to nobody
|
|
1228
|
+
// here. Reading needs no account. Adding does, because a public list with
|
|
1229
|
+
// nobody accountable for its rows is a list of whatever anyone felt like.
|
|
1230
|
+
if ((path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/")) && options.openDirs) {
|
|
1231
|
+
const dirs = options.openDirs;
|
|
1232
|
+
if (path === "/api/v1/opendirs" && request.method === "GET") {
|
|
1233
|
+
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
1234
|
+
const before = Number(url.searchParams.get("before") ?? "0");
|
|
1235
|
+
const page = await dirs.list(Number.isFinite(limit) ? limit : 50, Number.isFinite(before) ? before : 0);
|
|
1236
|
+
const names = options.handles ? await options.handles.many(page.addedBy) : new Map();
|
|
1237
|
+
json(response, 200, {
|
|
1238
|
+
opendirs: page.rows.map((row, at) => ({
|
|
1239
|
+
...row,
|
|
1240
|
+
// A handle or nothing. The address that signed up is never here.
|
|
1241
|
+
by: names.get(page.addedBy[at] ?? "") ?? "",
|
|
1242
|
+
})),
|
|
1243
|
+
next: page.next,
|
|
1244
|
+
});
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (path === "/api/v1/opendirs" && request.method === "POST" && options.accounts) {
|
|
1248
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1249
|
+
if (who === null) {
|
|
1250
|
+
json(response, 401, { error: "sign in to publish one" });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
let body;
|
|
1254
|
+
try {
|
|
1255
|
+
body = JSON.parse(await readBody(request));
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
json(response, 400, { error: "bad JSON" });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
// Read before publishing. A row nobody can play is worse than no row,
|
|
1262
|
+
// and the count is the one fact a reader wants before clicking.
|
|
1263
|
+
const listed = typeof body.url === "string" ? await readRemoteIndex(body.url) : [];
|
|
1264
|
+
if (listed.length === 0) {
|
|
1265
|
+
json(response, 422, { error: "nothing playable was linked from that page" });
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const made = await dirs.add(who.id, body.url, body.name, listed.length);
|
|
1269
|
+
if (made === null) {
|
|
1270
|
+
json(response, 422, { error: "that needs an http or https address" });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const handle = options.handles ? await options.handles.of(who.id) : "";
|
|
1274
|
+
json(response, 201, { opendir: { ...made, by: handle } });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const dirId = path.slice("/api/v1/opendirs/".length);
|
|
1278
|
+
if (dirId && request.method === "DELETE" && options.accounts) {
|
|
1279
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1280
|
+
if (who === null) {
|
|
1281
|
+
json(response, 401, { error: "not signed in" });
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const gone = await dirs.remove(who.id, dirId);
|
|
1285
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "not yours, or not there" });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1169
1291
|
// --- coming back from a provider ---------------------------------------
|
|
1170
1292
|
//
|
|
1171
1293
|
// /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
|
|
@@ -2274,7 +2396,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2274
2396
|
// all: a browser already signed in can approve a terminal.
|
|
2275
2397
|
// The same pool the follows and reminders use: three small tables in
|
|
2276
2398
|
// one database do not want three sets of connections.
|
|
2277
|
-
...(pool
|
|
2399
|
+
...(pool
|
|
2400
|
+
? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
|
|
2401
|
+
: {}),
|
|
2278
2402
|
signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
|
|
2279
2403
|
}
|
|
2280
2404
|
: {}),
|
package/dist/session.d.ts
CHANGED
|
@@ -107,3 +107,11 @@ export declare function whoami(fetcher?: typeof fetch): Promise<number>;
|
|
|
107
107
|
* the browser and in the desktop app.
|
|
108
108
|
*/
|
|
109
109
|
export declare function servers(argv: string[], fetcher?: typeof fetch): Promise<number>;
|
|
110
|
+
/**
|
|
111
|
+
* `nixamp opendir list|add|remove`, the public list of folders people found.
|
|
112
|
+
*
|
|
113
|
+
* Reading needs no account, which is why `list` works signed out. Adding needs
|
|
114
|
+
* one, because a public list with nobody accountable for its rows is a list of
|
|
115
|
+
* whatever anybody felt like putting there.
|
|
116
|
+
*/
|
|
117
|
+
export declare function opendirs(argv: string[], fetcher?: typeof fetch): Promise<number>;
|
package/dist/session.js
CHANGED
|
@@ -578,3 +578,89 @@ export async function servers(argv, fetcher = fetch) {
|
|
|
578
578
|
return 69;
|
|
579
579
|
}
|
|
580
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* `nixamp opendir list|add|remove`, the public list of folders people found.
|
|
583
|
+
*
|
|
584
|
+
* Reading needs no account, which is why `list` works signed out. Adding needs
|
|
585
|
+
* one, because a public list with nobody accountable for its rows is a list of
|
|
586
|
+
* whatever anybody felt like putting there.
|
|
587
|
+
*/
|
|
588
|
+
export async function opendirs(argv, fetcher = fetch) {
|
|
589
|
+
const [command = "list", ...rest] = argv;
|
|
590
|
+
const session = readSession();
|
|
591
|
+
const site = session?.site ?? DEFAULT_DIRECTORY;
|
|
592
|
+
const where = `${site}/api/v1/opendirs`;
|
|
593
|
+
const headers = {
|
|
594
|
+
"content-type": "application/json",
|
|
595
|
+
...(session ? { authorization: `Bearer ${session.token}` } : {}),
|
|
596
|
+
};
|
|
597
|
+
const at = (flag) => {
|
|
598
|
+
const index = rest.indexOf(flag);
|
|
599
|
+
return index === -1 ? undefined : rest[index + 1];
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
if (command === "list" || command === "ls") {
|
|
603
|
+
const answer = await fetcher(where, { headers });
|
|
604
|
+
const body = (await answer.json().catch(() => ({})));
|
|
605
|
+
if (!answer.ok) {
|
|
606
|
+
console.error(`nixamp: ${body.error ?? `could not read the list (${answer.status})`}`);
|
|
607
|
+
return 1;
|
|
608
|
+
}
|
|
609
|
+
const rows = body.opendirs ?? [];
|
|
610
|
+
if (rows.length === 0) {
|
|
611
|
+
console.log("Nothing published yet. `nixamp opendir add <url>` puts one there.");
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
const width = Math.max(...rows.map((row) => row.name.length));
|
|
615
|
+
for (const row of rows) {
|
|
616
|
+
const who = row.by ? ` by ${row.by}` : "";
|
|
617
|
+
console.log(`${row.id} ${row.name.padEnd(width)} ${row.tracks} tracks${who}`);
|
|
618
|
+
console.log(` ${row.url}`);
|
|
619
|
+
}
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
if (session === null) {
|
|
623
|
+
console.error("nixamp: sign in to publish or remove one. Try `nixamp login`.");
|
|
624
|
+
return 1;
|
|
625
|
+
}
|
|
626
|
+
if (command === "add" || command === "publish") {
|
|
627
|
+
const target = rest.find((a) => /^https?:\/\//.test(a)) ?? "";
|
|
628
|
+
if (!target) {
|
|
629
|
+
console.error("nixamp: which folder? Give the URL of a directory listing.");
|
|
630
|
+
return 64;
|
|
631
|
+
}
|
|
632
|
+
const answer = await fetcher(where, {
|
|
633
|
+
method: "POST",
|
|
634
|
+
headers,
|
|
635
|
+
body: JSON.stringify({ url: target, name: at("--name") ?? "" }),
|
|
636
|
+
});
|
|
637
|
+
const body = (await answer.json().catch(() => ({})));
|
|
638
|
+
if (!answer.ok || !body.opendir) {
|
|
639
|
+
console.error(`nixamp: ${body.error ?? `could not publish it (${answer.status})`}`);
|
|
640
|
+
return 1;
|
|
641
|
+
}
|
|
642
|
+
console.log(`${body.opendir.id} ${body.opendir.name} ${body.opendir.tracks} tracks`);
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
if (command === "remove" || command === "rm") {
|
|
646
|
+
const id = rest.find((a) => !a.startsWith("-")) ?? "";
|
|
647
|
+
if (!id) {
|
|
648
|
+
console.error("nixamp: which one? `nixamp opendir list` shows their ids.");
|
|
649
|
+
return 64;
|
|
650
|
+
}
|
|
651
|
+
const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
|
|
652
|
+
if (!answer.ok) {
|
|
653
|
+
console.error(`nixamp: ${answer.status === 404 ? "not yours, or not there" : "could not remove it"}`);
|
|
654
|
+
return 1;
|
|
655
|
+
}
|
|
656
|
+
console.log(`Removed ${id}.`);
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
console.error(`nixamp: no such opendir command: ${command}`);
|
|
660
|
+
return 64;
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
console.error(`nixamp: could not reach ${site}: ${error.message}`);
|
|
664
|
+
return 69;
|
|
665
|
+
}
|
|
666
|
+
}
|
package/package.json
CHANGED
package/src/handles.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name other people see.
|
|
3
|
+
*
|
|
4
|
+
* An account is keyed on an email address, because that is what the auth module
|
|
5
|
+
* authenticates. An address is a credential and a way to reach somebody, and it
|
|
6
|
+
* is not a name: putting it in a directory listing, an invite or a subdomain
|
|
7
|
+
* publishes something the account holder gave us to log in with.
|
|
8
|
+
*
|
|
9
|
+
* So there are two names. The address stays private and does the linking, and a
|
|
10
|
+
* handle is the one that appears in front of strangers. They are never the same
|
|
11
|
+
* field and never travel in the same response.
|
|
12
|
+
*/
|
|
13
|
+
import type { Queryable } from "./follows.ts";
|
|
14
|
+
|
|
15
|
+
const TABLE = "nixamp_handles";
|
|
16
|
+
|
|
17
|
+
const SCHEMA = `
|
|
18
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
19
|
+
user_id TEXT PRIMARY KEY,
|
|
20
|
+
handle TEXT NOT NULL,
|
|
21
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
22
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
23
|
+
);
|
|
24
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a handle may be.
|
|
29
|
+
*
|
|
30
|
+
* It ends up in a URL, a subdomain and a text message, so it is the intersection
|
|
31
|
+
* of what all three tolerate: lowercase letters, digits and hyphens, not
|
|
32
|
+
* starting or ending with one. Two to thirty characters, because a subdomain
|
|
33
|
+
* label cannot exceed sixty-three and nobody types thirty.
|
|
34
|
+
*/
|
|
35
|
+
export function cleanHandle(value: unknown): string {
|
|
36
|
+
if (typeof value !== "string") return "";
|
|
37
|
+
const wanted = value.trim().toLowerCase();
|
|
38
|
+
// Two characters minimum, thirty maximum: the trailing character is required,
|
|
39
|
+
// which is also what stops a handle ending in a hyphen.
|
|
40
|
+
if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(wanted)) return "";
|
|
41
|
+
// Doubled hyphens are how punycode marks an encoded label, so a handle with
|
|
42
|
+
// one in it can collide with an internationalised domain.
|
|
43
|
+
return wanted.includes("--") ? "" : wanted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Names nobody may take, because a subdomain carrying one would impersonate the
|
|
48
|
+
* service or reach a machine we run.
|
|
49
|
+
*/
|
|
50
|
+
const RESERVED = new Set([
|
|
51
|
+
"www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
|
|
52
|
+
"static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
|
|
53
|
+
"status", "blog", "directory", "login", "signup", "account", "settings", "me",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export function isReserved(handle: string): boolean {
|
|
57
|
+
return RESERVED.has(handle);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A handle for somebody who has not chosen one.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately not derived from the address. "anthony@profullstack.com" turning
|
|
64
|
+
* into "anthony" is exactly the leak this whole file exists to avoid, and it
|
|
65
|
+
* would be a leak nobody noticed until it was already in a directory listing.
|
|
66
|
+
*/
|
|
67
|
+
export function anonymousHandle(random: (size: number) => Uint8Array): string {
|
|
68
|
+
const bytes = random(4);
|
|
69
|
+
let out = "";
|
|
70
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
71
|
+
return `nixamp-${out}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class Handles {
|
|
75
|
+
private ready: Promise<void> | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(private readonly db: Queryable) {}
|
|
78
|
+
|
|
79
|
+
private async ensure(): Promise<void> {
|
|
80
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
81
|
+
await this.ready;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async of(userId: string): Promise<string> {
|
|
85
|
+
await this.ensure();
|
|
86
|
+
const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
87
|
+
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Handles for a page of rows, in one query.
|
|
92
|
+
*
|
|
93
|
+
* A public listing names people, and naming them one query at a time is how a
|
|
94
|
+
* list of fifty becomes fifty round trips. Anyone without a handle is absent
|
|
95
|
+
* from the map rather than present as an empty string, so a caller decides
|
|
96
|
+
* what to show for somebody who never picked one.
|
|
97
|
+
*/
|
|
98
|
+
async many(userIds: string[]): Promise<Map<string, string>> {
|
|
99
|
+
const wanted = [...new Set(userIds.filter(Boolean))];
|
|
100
|
+
if (wanted.length === 0) return new Map();
|
|
101
|
+
await this.ensure();
|
|
102
|
+
const { rows } = await this.db.query(
|
|
103
|
+
`SELECT user_id, handle FROM ${TABLE} WHERE user_id = ANY($1)`,
|
|
104
|
+
[wanted],
|
|
105
|
+
);
|
|
106
|
+
return new Map(rows.map((row) => [String(row["user_id"] ?? ""), String(row["handle"] ?? "")]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
110
|
+
async holder(handle: string): Promise<string> {
|
|
111
|
+
const wanted = cleanHandle(handle);
|
|
112
|
+
if (!wanted) return "";
|
|
113
|
+
await this.ensure();
|
|
114
|
+
const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
|
|
115
|
+
return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Claim one. Answers the reason it could not be taken rather than a boolean,
|
|
120
|
+
* because "that is already somebody's" and "that is not a name" are different
|
|
121
|
+
* things to tell a person.
|
|
122
|
+
*/
|
|
123
|
+
async claim(userId: string, wanted: unknown): Promise<{ handle: string; error: string }> {
|
|
124
|
+
const handle = cleanHandle(wanted);
|
|
125
|
+
if (!handle) {
|
|
126
|
+
return { handle: "", error: "letters, digits and hyphens, 2 to 30 characters" };
|
|
127
|
+
}
|
|
128
|
+
if (isReserved(handle)) return { handle: "", error: "that one is reserved" };
|
|
129
|
+
|
|
130
|
+
await this.ensure();
|
|
131
|
+
const taken = await this.holder(handle);
|
|
132
|
+
if (taken && taken !== userId) return { handle: "", error: "somebody already has that one" };
|
|
133
|
+
|
|
134
|
+
await this.db.query(
|
|
135
|
+
`INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
|
|
136
|
+
ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`,
|
|
137
|
+
[userId, handle],
|
|
138
|
+
);
|
|
139
|
+
return { handle, error: "" };
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -76,6 +76,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
76
76
|
nixamp logout / whoami forget it, or check it
|
|
77
77
|
nixamp token create|list|revoke tokens for a machine that cannot sign in
|
|
78
78
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
79
|
+
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
79
80
|
nixamp update [version] re-run the installer, keeping your choices
|
|
80
81
|
nixamp uninstall [--yes] remove everything the installer created
|
|
81
82
|
|
|
@@ -326,6 +327,11 @@ export async function main(): Promise<void> {
|
|
|
326
327
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
327
328
|
return;
|
|
328
329
|
}
|
|
330
|
+
if (first === "opendir" || first === "opendirs") {
|
|
331
|
+
const { opendirs } = await import("./session.ts");
|
|
332
|
+
process.exitCode = await opendirs(rest);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
329
335
|
if (first === "server" || first === "servers") {
|
|
330
336
|
const { servers } = await import("./session.ts");
|
|
331
337
|
process.exitCode = await servers(rest);
|
package/src/opendirs.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open directories somebody found, kept as a list anyone can read.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the stream directory. That one lists what is playing right
|
|
5
|
+
* now: a heartbeat, a TTL, a room code, somebody at the other end. An open
|
|
6
|
+
* directory is the opposite in every one of those respects. It is always there,
|
|
7
|
+
* nobody is broadcasting it, and it is not the finder's to broadcast. Mixing
|
|
8
|
+
* the two would put "chovy is playing this, dial in" next to "here is a link
|
|
9
|
+
* to a stranger's file server" under one heading.
|
|
10
|
+
*
|
|
11
|
+
* Public to read and signed in to add, because a public list with nobody
|
|
12
|
+
* accountable for its rows is a public list of whatever anybody felt like
|
|
13
|
+
* putting there. What is shown against a row is the finder's handle, never the
|
|
14
|
+
* address they signed up with.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import type { Queryable } from "./follows.ts";
|
|
18
|
+
import { cleanUrl } from "./servers.ts";
|
|
19
|
+
|
|
20
|
+
const TABLE = "nixamp_opendirs";
|
|
21
|
+
|
|
22
|
+
const SCHEMA = `
|
|
23
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
url TEXT NOT NULL,
|
|
26
|
+
name TEXT NOT NULL DEFAULT '',
|
|
27
|
+
added_by TEXT NOT NULL,
|
|
28
|
+
tracks INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
30
|
+
);
|
|
31
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_url ON ${TABLE} (url);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_added ON ${TABLE} (created_at DESC);
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export interface OpenDir {
|
|
36
|
+
id: string;
|
|
37
|
+
url: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** How many playable files were on the page when it was added. */
|
|
40
|
+
tracks: number;
|
|
41
|
+
/** The finder's public handle. Never their address. */
|
|
42
|
+
by: string;
|
|
43
|
+
createdAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The name to show for a folder nobody named.
|
|
48
|
+
*
|
|
49
|
+
* The last path segment, which for a folder of music is the album, written the
|
|
50
|
+
* way a person wrote it rather than the way a URL spells it.
|
|
51
|
+
*/
|
|
52
|
+
export function nameOfDir(url: string): string {
|
|
53
|
+
try {
|
|
54
|
+
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
55
|
+
const last = parts[parts.length - 1];
|
|
56
|
+
return last ? decodeURIComponent(last).slice(0, 120) : new URL(url).host;
|
|
57
|
+
} catch {
|
|
58
|
+
return "an open directory";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asTime(value: unknown): number {
|
|
63
|
+
if (value instanceof Date) return value.getTime();
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
const at = Date.parse(value);
|
|
66
|
+
return Number.isNaN(at) ? 0 : at;
|
|
67
|
+
}
|
|
68
|
+
return typeof value === "number" ? value : 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class OpenDirs {
|
|
72
|
+
private ready: Promise<void> | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly db: Queryable) {}
|
|
75
|
+
|
|
76
|
+
private async ensure(): Promise<void> {
|
|
77
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
78
|
+
await this.ready;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The list, newest first, in pages.
|
|
83
|
+
*
|
|
84
|
+
* Keyset on the row's own creation time rather than an offset, because a list
|
|
85
|
+
* anybody may add to shifts under a reader who is paging through it, and an
|
|
86
|
+
* offset in a shifting list repeats and skips rows.
|
|
87
|
+
*/
|
|
88
|
+
async list(limit = 50, before = 0): Promise<{ rows: Omit<OpenDir, "by">[]; addedBy: string[]; next: number }> {
|
|
89
|
+
await this.ensure();
|
|
90
|
+
const size = Math.min(200, Math.max(1, limit));
|
|
91
|
+
const { rows } = await this.db.query(
|
|
92
|
+
`SELECT id, url, name, added_by, tracks, created_at FROM ${TABLE}
|
|
93
|
+
${before > 0 ? "WHERE created_at < $2" : ""}
|
|
94
|
+
ORDER BY created_at DESC LIMIT $1`,
|
|
95
|
+
before > 0 ? [size + 1, new Date(before).toISOString()] : [size + 1],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// One more than asked for, so "is there another page" is a fact rather
|
|
99
|
+
// than a guess from a full one.
|
|
100
|
+
const page = rows.slice(0, size);
|
|
101
|
+
return {
|
|
102
|
+
rows: page.map((row) => ({
|
|
103
|
+
id: String(row["id"] ?? ""),
|
|
104
|
+
url: String(row["url"] ?? ""),
|
|
105
|
+
name: String(row["name"] ?? ""),
|
|
106
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
107
|
+
createdAt: asTime(row["created_at"]),
|
|
108
|
+
})),
|
|
109
|
+
addedBy: page.map((row) => String(row["added_by"] ?? "")),
|
|
110
|
+
next: rows.length > size ? asTime(page[page.length - 1]?.["created_at"]) : 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Publish one. `tracks` is what the finder's server actually saw on the page,
|
|
116
|
+
* so a row nobody can play never reaches the list.
|
|
117
|
+
*/
|
|
118
|
+
async add(userId: string, url: unknown, name: unknown, tracks: number): Promise<Omit<OpenDir, "by"> | null> {
|
|
119
|
+
const clean = typeof url === "string" ? url.trim() : "";
|
|
120
|
+
// Not cleanUrl's origin-only treatment: a folder is a path, and the path is
|
|
121
|
+
// the whole point of the row.
|
|
122
|
+
if (!/^https?:\/\//i.test(clean) || cleanUrl(clean) === "") return null;
|
|
123
|
+
if (tracks <= 0) return null;
|
|
124
|
+
|
|
125
|
+
await this.ensure();
|
|
126
|
+
const { rows } = await this.db.query(
|
|
127
|
+
`INSERT INTO ${TABLE} (id, url, name, added_by, tracks) VALUES ($1, $2, $3, $4, $5)
|
|
128
|
+
ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, tracks = EXCLUDED.tracks
|
|
129
|
+
RETURNING id, url, name, tracks, created_at`,
|
|
130
|
+
[
|
|
131
|
+
randomBytes(8).toString("hex"),
|
|
132
|
+
clean,
|
|
133
|
+
(typeof name === "string" && name.trim() ? name.trim() : nameOfDir(clean)).slice(0, 120),
|
|
134
|
+
userId,
|
|
135
|
+
Math.min(100_000, tracks),
|
|
136
|
+
],
|
|
137
|
+
);
|
|
138
|
+
const row = rows[0];
|
|
139
|
+
if (!row) return null;
|
|
140
|
+
return {
|
|
141
|
+
id: String(row["id"] ?? ""),
|
|
142
|
+
url: String(row["url"] ?? ""),
|
|
143
|
+
name: String(row["name"] ?? ""),
|
|
144
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
145
|
+
createdAt: asTime(row["created_at"]),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Only the finder takes their own row down. */
|
|
150
|
+
async remove(userId: string, id: string): Promise<boolean> {
|
|
151
|
+
await this.ensure();
|
|
152
|
+
const { rows } = await this.db.query(
|
|
153
|
+
`DELETE FROM ${TABLE} WHERE id = $1 AND added_by = $2 RETURNING id`,
|
|
154
|
+
[id, userId],
|
|
155
|
+
);
|
|
156
|
+
return rows.length > 0;
|
|
157
|
+
}
|
|
158
|
+
}
|