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/src/playlist.ts CHANGED
@@ -87,6 +87,64 @@ export async function readPlaylist(source: string): Promise<Entry[]> {
87
87
  return entries;
88
88
  }
89
89
 
90
+ /**
91
+ * The audio linked from a directory listing a web server generated.
92
+ *
93
+ * A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
94
+ * of relative links. Handed one of those, nixamp used to make a single track of
95
+ * the page itself and give it to ffmpeg, which is asked to decode HTML and says
96
+ * so in a way nobody reads. It is a folder; it should behave like one.
97
+ *
98
+ * Not recursive, deliberately: one page is one album, the subdirectory links are
99
+ * on it, and walking a stranger's whole tree from a text box is a different and
100
+ * much larger thing to ask for.
101
+ */
102
+ export async function readRemoteIndex(source: string, send: typeof fetch = fetch): Promise<Entry[]> {
103
+ let answer: Response;
104
+ try {
105
+ answer = await send(source, {
106
+ redirect: "follow",
107
+ headers: { accept: "text/html,*/*" },
108
+ signal: AbortSignal.timeout(15_000),
109
+ });
110
+ } catch {
111
+ return [];
112
+ }
113
+ if (!answer.ok) return [];
114
+ // Anything that is not a page is the thing itself, and the caller plays it.
115
+ if (!/^text\/html/i.test(answer.headers.get("content-type") ?? "")) return [];
116
+
117
+ const html = await answer.text().catch(() => "");
118
+ const found: Entry[] = [];
119
+ const seen = new Set<string>();
120
+ for (const match of html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
121
+ const href = match[1];
122
+ // The sort links an index puts at the top of every column, and anchors.
123
+ if (!href || href.startsWith("?") || href.startsWith("#")) continue;
124
+ let url: URL;
125
+ try {
126
+ // Relative to the page, which is how an index writes them.
127
+ url = new URL(href.replace(/&amp;/g, "&"), source);
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (!isAudio(url.pathname)) continue;
132
+ const link = url.toString();
133
+ if (seen.has(link)) continue;
134
+ seen.add(link);
135
+ found.push({
136
+ source: link,
137
+ // The name as a person wrote it, not as a URL spells it.
138
+ title: decodeURIComponent(url.pathname.split("/").pop() ?? link),
139
+ duration: 0,
140
+ });
141
+ }
142
+ // Server order is by whatever column the index sorted on; by name is what
143
+ // somebody handing over an album meant.
144
+ found.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
145
+ return found;
146
+ }
147
+
90
148
  /**
91
149
  * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
92
150
  * or a URL to any of those.
@@ -112,9 +170,18 @@ export async function loadSource(tools: Tools, source: string, probeTags = true)
112
170
  );
113
171
  }
114
172
 
115
- // A bare URL is one remote thing to play. Whether it is a song or a live
116
- // stream is ffmpeg's problem, and it is good at it.
117
- if (isRemote(source)) return [bare({ source, title: nameOf(source), duration: 0 })];
173
+ if (isRemote(source)) {
174
+ // A URL that names no file is probably a folder, and a folder served over
175
+ // http is a page of links. Asked only when it could be one: a stream URL
176
+ // must not pay for a fetch that will tell us nothing.
177
+ if (!isAudio(new URL(source).pathname)) {
178
+ const listed = await readRemoteIndex(source);
179
+ if (listed.length > 0) return listed.map(bare);
180
+ }
181
+ // A bare URL is one remote thing to play. Whether it is a song or a live
182
+ // stream is ffmpeg's problem, and it is good at it.
183
+ return [bare({ source, title: nameOf(source), duration: 0 })];
184
+ }
118
185
 
119
186
  return loadPlaylist(tools, source, probeTags);
120
187
  }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
14
  import { createServer as createHttpsServer } from "node:https";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { hostname, networkInterfaces } from "node:os";
16
17
  import { spawn, spawnSync } from "node:child_process";
17
18
  import { readFileSync } from "node:fs";
@@ -28,6 +29,8 @@ import { Ingest, normaliseFormat } from "./ingest.ts";
28
29
  import { Channels, cleanId } from "./channels.ts";
29
30
  import { RtmpListeners } from "./rtmp-in.ts";
30
31
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
32
+ import { anonymousHandle, Handles } from "./handles.ts";
33
+ import { OpenDirs } from "./opendirs.ts";
31
34
  import { Servers } from "./servers.ts";
32
35
  import { DeviceGrants } from "./device.ts";
33
36
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
@@ -80,7 +83,7 @@ import {
80
83
  type Tools, type Track,
81
84
  } from "./audio.ts";
82
85
  import { Analyser, bandEdges, bands, decay } from "./fft.ts";
83
- import { loadSource, loadTagged } from "./playlist.ts";
86
+ import { loadSource, loadTagged, readRemoteIndex } from "./playlist.ts";
84
87
  import {
85
88
  emptySnapshot, parseCommand,
86
89
  type Command, type RemoteTrack, type Snapshot,
@@ -692,6 +695,10 @@ export function isSignInPath(path: string): boolean {
692
695
  // rather than by a share key it has nothing to do with.
693
696
  path === "/api/v1/servers" ||
694
697
  path.startsWith("/api/v1/servers/") ||
698
+ path === "/api/v1/me/handle" ||
699
+ // Public to read, so it must not be behind a share key either.
700
+ path === "/api/v1/opendirs" ||
701
+ path.startsWith("/api/v1/opendirs/") ||
695
702
  OAUTH_ROUTE.test(path)
696
703
  );
697
704
  }
@@ -778,6 +785,10 @@ export interface HandlerOptions {
778
785
  signIn?: SignIn;
779
786
  /** The servers each account runs, on the instance that keeps accounts. */
780
787
  servers?: Servers;
788
+ /** The name other people see, which is never the address they signed up with. */
789
+ handles?: Handles;
790
+ /** Open directories people have found, which anyone may read. */
791
+ openDirs?: OpenDirs;
781
792
  /** True when this instance is reached over https, for the cookie's Secure. */
782
793
  secureCookies?: boolean;
783
794
  /** A certificate and key in PEM, when this server is to speak https itself. */
@@ -1256,6 +1267,43 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1256
1267
  return;
1257
1268
  }
1258
1269
 
1270
+ // --- the name other people see ---------------------------------------
1271
+ //
1272
+ // Separate from the address on purpose. The address is a credential and
1273
+ // a way to reach somebody; publishing it in a directory listing or an
1274
+ // invite would be publishing what they log in with.
1275
+ if (path === "/api/v1/me/handle" && options.handles) {
1276
+ const handles = options.handles;
1277
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1278
+ if (who === null) {
1279
+ json(response, 401, { error: "not signed in" });
1280
+ return;
1281
+ }
1282
+
1283
+ if (request.method === "GET") {
1284
+ json(response, 200, { handle: await handles.of(who.id) });
1285
+ return;
1286
+ }
1287
+ if (request.method === "PUT" || request.method === "POST") {
1288
+ let body: { handle?: unknown };
1289
+ try {
1290
+ body = JSON.parse(await readBody(request)) as typeof body;
1291
+ } catch {
1292
+ json(response, 400, { error: "bad JSON" });
1293
+ return;
1294
+ }
1295
+ const claimed = await handles.claim(who.id, body.handle);
1296
+ if (claimed.error) {
1297
+ json(response, 409, { error: claimed.error });
1298
+ return;
1299
+ }
1300
+ json(response, 200, { handle: claimed.handle });
1301
+ return;
1302
+ }
1303
+ json(response, 405, { error: "GET or PUT" });
1304
+ return;
1305
+ }
1306
+
1259
1307
  // --- the servers this account runs ----------------------------------
1260
1308
  //
1261
1309
  // Kept against the account rather than the machine, so the list reads the
@@ -1413,6 +1461,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1413
1461
  ? await accounts.signUp(body.email, body.password)
1414
1462
  : await accounts.signIn(body.email, body.password);
1415
1463
 
1464
+ // A handle asked for at sign-up, or one nobody has to think about. Never
1465
+ // derived from the address: turning anthony@… into "anthony" is the leak
1466
+ // this whole idea exists to avoid, and it is one nobody would notice
1467
+ // until it was already in a directory listing.
1468
+ if (signingUp && result.ok && result.account && options.handles) {
1469
+ const asked = (body as { handle?: unknown }).handle;
1470
+ const claimed = await options.handles.claim(result.account.id, asked);
1471
+ if (claimed.error) {
1472
+ await options.handles.claim(result.account.id, anonymousHandle((size) => randomBytes(size)));
1473
+ }
1474
+ }
1475
+
1416
1476
  // Getting it right costs nothing: the counters only exist to stop people
1417
1477
  // who keep getting it wrong.
1418
1478
  if (result.ok) for (const bucket of buckets) guard.forget(bucket);
@@ -1441,6 +1501,79 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1441
1501
  return;
1442
1502
  }
1443
1503
 
1504
+ // --- open directories somebody found -----------------------------------
1505
+ //
1506
+ // Its own list, not the stream directory: that one is what is playing now,
1507
+ // with a heartbeat and a room code and somebody at the other end, and this
1508
+ // one is a folder on the web that is always there and belongs to nobody
1509
+ // here. Reading needs no account. Adding does, because a public list with
1510
+ // nobody accountable for its rows is a list of whatever anyone felt like.
1511
+ if ((path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/")) && options.openDirs) {
1512
+ const dirs = options.openDirs;
1513
+
1514
+ if (path === "/api/v1/opendirs" && request.method === "GET") {
1515
+ const limit = Number(url.searchParams.get("limit") ?? "50");
1516
+ const before = Number(url.searchParams.get("before") ?? "0");
1517
+ const page = await dirs.list(Number.isFinite(limit) ? limit : 50, Number.isFinite(before) ? before : 0);
1518
+ const names = options.handles ? await options.handles.many(page.addedBy) : new Map<string, string>();
1519
+ json(response, 200, {
1520
+ opendirs: page.rows.map((row, at) => ({
1521
+ ...row,
1522
+ // A handle or nothing. The address that signed up is never here.
1523
+ by: names.get(page.addedBy[at] ?? "") ?? "",
1524
+ })),
1525
+ next: page.next,
1526
+ });
1527
+ return;
1528
+ }
1529
+
1530
+ if (path === "/api/v1/opendirs" && request.method === "POST" && options.accounts) {
1531
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1532
+ if (who === null) {
1533
+ json(response, 401, { error: "sign in to publish one" });
1534
+ return;
1535
+ }
1536
+ let body: { url?: unknown; name?: unknown };
1537
+ try {
1538
+ body = JSON.parse(await readBody(request)) as typeof body;
1539
+ } catch {
1540
+ json(response, 400, { error: "bad JSON" });
1541
+ return;
1542
+ }
1543
+
1544
+ // Read before publishing. A row nobody can play is worse than no row,
1545
+ // and the count is the one fact a reader wants before clicking.
1546
+ const listed = typeof body.url === "string" ? await readRemoteIndex(body.url) : [];
1547
+ if (listed.length === 0) {
1548
+ json(response, 422, { error: "nothing playable was linked from that page" });
1549
+ return;
1550
+ }
1551
+ const made = await dirs.add(who.id, body.url, body.name, listed.length);
1552
+ if (made === null) {
1553
+ json(response, 422, { error: "that needs an http or https address" });
1554
+ return;
1555
+ }
1556
+ const handle = options.handles ? await options.handles.of(who.id) : "";
1557
+ json(response, 201, { opendir: { ...made, by: handle } });
1558
+ return;
1559
+ }
1560
+
1561
+ const dirId = path.slice("/api/v1/opendirs/".length);
1562
+ if (dirId && request.method === "DELETE" && options.accounts) {
1563
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1564
+ if (who === null) {
1565
+ json(response, 401, { error: "not signed in" });
1566
+ return;
1567
+ }
1568
+ const gone = await dirs.remove(who.id, dirId);
1569
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "not yours, or not there" });
1570
+ return;
1571
+ }
1572
+
1573
+ json(response, 405, { error: "GET, POST or DELETE" });
1574
+ return;
1575
+ }
1576
+
1444
1577
  // --- coming back from a provider ---------------------------------------
1445
1578
  //
1446
1579
  // /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
@@ -2624,7 +2757,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2624
2757
  // all: a browser already signed in can approve a terminal.
2625
2758
  // The same pool the follows and reminders use: three small tables in
2626
2759
  // one database do not want three sets of connections.
2627
- ...(pool ? { servers: new Servers(pool) } : {}),
2760
+ ...(pool
2761
+ ? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
2762
+ : {}),
2628
2763
  signIn: new SignIn(
2629
2764
  providersFrom(process.env),
2630
2765
  new DeviceGrants(),
package/src/session.ts CHANGED
@@ -659,3 +659,103 @@ interface ServerRow {
659
659
  url: string;
660
660
  key: string;
661
661
  }
662
+
663
+ /** One row of the public open-directory list, as the API sends it. */
664
+ interface OpenDirRow {
665
+ id: string;
666
+ url: string;
667
+ name: string;
668
+ tracks: number;
669
+ by: string;
670
+ }
671
+
672
+ /**
673
+ * `nixamp opendir list|add|remove`, the public list of folders people found.
674
+ *
675
+ * Reading needs no account, which is why `list` works signed out. Adding needs
676
+ * one, because a public list with nobody accountable for its rows is a list of
677
+ * whatever anybody felt like putting there.
678
+ */
679
+ export async function opendirs(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
680
+ const [command = "list", ...rest] = argv;
681
+ const session = readSession();
682
+ const site = session?.site ?? DEFAULT_DIRECTORY;
683
+ const where = `${site}/api/v1/opendirs`;
684
+ const headers: Record<string, string> = {
685
+ "content-type": "application/json",
686
+ ...(session ? { authorization: `Bearer ${session.token}` } : {}),
687
+ };
688
+ const at = (flag: string): string | undefined => {
689
+ const index = rest.indexOf(flag);
690
+ return index === -1 ? undefined : rest[index + 1];
691
+ };
692
+
693
+ try {
694
+ if (command === "list" || command === "ls") {
695
+ const answer = await fetcher(where, { headers });
696
+ const body = (await answer.json().catch(() => ({}))) as { opendirs?: OpenDirRow[]; error?: string };
697
+ if (!answer.ok) {
698
+ console.error(`nixamp: ${body.error ?? `could not read the list (${answer.status})`}`);
699
+ return 1;
700
+ }
701
+ const rows = body.opendirs ?? [];
702
+ if (rows.length === 0) {
703
+ console.log("Nothing published yet. `nixamp opendir add <url>` puts one there.");
704
+ return 0;
705
+ }
706
+ const width = Math.max(...rows.map((row) => row.name.length));
707
+ for (const row of rows) {
708
+ const who = row.by ? ` by ${row.by}` : "";
709
+ console.log(`${row.id} ${row.name.padEnd(width)} ${row.tracks} tracks${who}`);
710
+ console.log(` ${row.url}`);
711
+ }
712
+ return 0;
713
+ }
714
+
715
+ if (session === null) {
716
+ console.error("nixamp: sign in to publish or remove one. Try `nixamp login`.");
717
+ return 1;
718
+ }
719
+
720
+ if (command === "add" || command === "publish") {
721
+ const target = rest.find((a) => /^https?:\/\//.test(a)) ?? "";
722
+ if (!target) {
723
+ console.error("nixamp: which folder? Give the URL of a directory listing.");
724
+ return 64;
725
+ }
726
+ const answer = await fetcher(where, {
727
+ method: "POST",
728
+ headers,
729
+ body: JSON.stringify({ url: target, name: at("--name") ?? "" }),
730
+ });
731
+ const body = (await answer.json().catch(() => ({}))) as { opendir?: OpenDirRow; error?: string };
732
+ if (!answer.ok || !body.opendir) {
733
+ console.error(`nixamp: ${body.error ?? `could not publish it (${answer.status})`}`);
734
+ return 1;
735
+ }
736
+ console.log(`${body.opendir.id} ${body.opendir.name} ${body.opendir.tracks} tracks`);
737
+ return 0;
738
+ }
739
+
740
+ if (command === "remove" || command === "rm") {
741
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
742
+ if (!id) {
743
+ console.error("nixamp: which one? `nixamp opendir list` shows their ids.");
744
+ return 64;
745
+ }
746
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
747
+ if (!answer.ok) {
748
+ console.error(`nixamp: ${answer.status === 404 ? "not yours, or not there" : "could not remove it"}`);
749
+ return 1;
750
+ }
751
+ console.log(`Removed ${id}.`);
752
+ return 0;
753
+ }
754
+
755
+ console.error(`nixamp: no such opendir command: ${command}`);
756
+ return 64;
757
+ } catch (error) {
758
+ console.error(`nixamp: could not reach ${site}: ${(error as Error).message}`);
759
+ return 69;
760
+ }
761
+ }
@@ -173,5 +173,6 @@
173
173
  <a href="https://github.com/profullstack/nixamp">github.com/profullstack/nixamp</a>
174
174
  </footer>
175
175
  </main>
176
+ <script data-site="f6b791a3-13e0-4212-a2c9-abafc3b7aff0" src="https://crawlproof.com/stats.js" async></script>
176
177
  </body>
177
178
  </html>
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788945118927";
2
+ const CACHE = "nixamp-1788946637747";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",