nixamp 0.7.0 → 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 +9 -0
- package/dist/handles.js +19 -1
- package/dist/main.js +6 -0
- package/dist/opendirs.d.ts +43 -0
- package/dist/opendirs.js +133 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +76 -2
- package/dist/session.d.ts +8 -0
- package/dist/session.js +86 -0
- package/package.json +1 -1
- package/src/handles.ts +22 -1
- package/src/main.ts +6 -0
- package/src/opendirs.ts +158 -0
- package/src/server.ts +83 -2
- package/src/session.ts +100 -0
- package/web/dist/index.html +1 -0
- package/web/dist/sw.js +1 -1
package/dist/handles.d.ts
CHANGED
|
@@ -35,6 +35,15 @@ export declare class Handles {
|
|
|
35
35
|
constructor(db: Queryable);
|
|
36
36
|
private ensure;
|
|
37
37
|
of(userId: string): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Handles for a page of rows, in one query.
|
|
40
|
+
*
|
|
41
|
+
* A public listing names people, and naming them one query at a time is how a
|
|
42
|
+
* list of fifty becomes fifty round trips. Anyone without a handle is absent
|
|
43
|
+
* from the map rather than present as an empty string, so a caller decides
|
|
44
|
+
* what to show for somebody who never picked one.
|
|
45
|
+
*/
|
|
46
|
+
many(userIds: string[]): Promise<Map<string, string>>;
|
|
38
47
|
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
39
48
|
holder(handle: string): Promise<string>;
|
|
40
49
|
/**
|
package/dist/handles.js
CHANGED
|
@@ -20,7 +20,9 @@ export function cleanHandle(value) {
|
|
|
20
20
|
if (typeof value !== "string")
|
|
21
21
|
return "";
|
|
22
22
|
const wanted = value.trim().toLowerCase();
|
|
23
|
-
|
|
23
|
+
// Two characters minimum, thirty maximum: the trailing character is required,
|
|
24
|
+
// which is also what stops a handle ending in a hyphen.
|
|
25
|
+
if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(wanted))
|
|
24
26
|
return "";
|
|
25
27
|
// Doubled hyphens are how punycode marks an encoded label, so a handle with
|
|
26
28
|
// one in it can collide with an internationalised domain.
|
|
@@ -67,6 +69,22 @@ export class Handles {
|
|
|
67
69
|
const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
68
70
|
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Handles for a page of rows, in one query.
|
|
74
|
+
*
|
|
75
|
+
* A public listing names people, and naming them one query at a time is how a
|
|
76
|
+
* list of fifty becomes fifty round trips. Anyone without a handle is absent
|
|
77
|
+
* from the map rather than present as an empty string, so a caller decides
|
|
78
|
+
* what to show for somebody who never picked one.
|
|
79
|
+
*/
|
|
80
|
+
async many(userIds) {
|
|
81
|
+
const wanted = [...new Set(userIds.filter(Boolean))];
|
|
82
|
+
if (wanted.length === 0)
|
|
83
|
+
return new Map();
|
|
84
|
+
await this.ensure();
|
|
85
|
+
const { rows } = await this.db.query(`SELECT user_id, handle FROM ${TABLE} WHERE user_id = ANY($1)`, [wanted]);
|
|
86
|
+
return new Map(rows.map((row) => [String(row["user_id"] ?? ""), String(row["handle"] ?? "")]));
|
|
87
|
+
}
|
|
70
88
|
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
71
89
|
async holder(handle) {
|
|
72
90
|
const wanted = cleanHandle(handle);
|
package/dist/main.js
CHANGED
|
@@ -52,6 +52,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
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
54
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
55
|
+
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
55
56
|
nixamp update [version] re-run the installer, keeping your choices
|
|
56
57
|
nixamp uninstall [--yes] remove everything the installer created
|
|
57
58
|
|
|
@@ -292,6 +293,11 @@ export async function main() {
|
|
|
292
293
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
if (first === "opendir" || first === "opendirs") {
|
|
297
|
+
const { opendirs } = await import("./session.js");
|
|
298
|
+
process.exitCode = await opendirs(rest);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
295
301
|
if (first === "server" || first === "servers") {
|
|
296
302
|
const { servers } = await import("./session.js");
|
|
297
303
|
process.exitCode = await servers(rest);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export interface OpenDir {
|
|
3
|
+
id: string;
|
|
4
|
+
url: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** How many playable files were on the page when it was added. */
|
|
7
|
+
tracks: number;
|
|
8
|
+
/** The finder's public handle. Never their address. */
|
|
9
|
+
by: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The name to show for a folder nobody named.
|
|
14
|
+
*
|
|
15
|
+
* The last path segment, which for a folder of music is the album, written the
|
|
16
|
+
* way a person wrote it rather than the way a URL spells it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function nameOfDir(url: string): string;
|
|
19
|
+
export declare class OpenDirs {
|
|
20
|
+
private readonly db;
|
|
21
|
+
private ready;
|
|
22
|
+
constructor(db: Queryable);
|
|
23
|
+
private ensure;
|
|
24
|
+
/**
|
|
25
|
+
* The list, newest first, in pages.
|
|
26
|
+
*
|
|
27
|
+
* Keyset on the row's own creation time rather than an offset, because a list
|
|
28
|
+
* anybody may add to shifts under a reader who is paging through it, and an
|
|
29
|
+
* offset in a shifting list repeats and skips rows.
|
|
30
|
+
*/
|
|
31
|
+
list(limit?: number, before?: number): Promise<{
|
|
32
|
+
rows: Omit<OpenDir, "by">[];
|
|
33
|
+
addedBy: string[];
|
|
34
|
+
next: number;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Publish one. `tracks` is what the finder's server actually saw on the page,
|
|
38
|
+
* so a row nobody can play never reaches the list.
|
|
39
|
+
*/
|
|
40
|
+
add(userId: string, url: unknown, name: unknown, tracks: number): Promise<Omit<OpenDir, "by"> | null>;
|
|
41
|
+
/** Only the finder takes their own row down. */
|
|
42
|
+
remove(userId: string, id: string): Promise<boolean>;
|
|
43
|
+
}
|
package/dist/opendirs.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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 { cleanUrl } from "./servers.js";
|
|
18
|
+
const TABLE = "nixamp_opendirs";
|
|
19
|
+
const SCHEMA = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
url TEXT NOT NULL,
|
|
23
|
+
name TEXT NOT NULL DEFAULT '',
|
|
24
|
+
added_by TEXT NOT NULL,
|
|
25
|
+
tracks INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
27
|
+
);
|
|
28
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_url ON ${TABLE} (url);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_added ON ${TABLE} (created_at DESC);
|
|
30
|
+
`;
|
|
31
|
+
/**
|
|
32
|
+
* The name to show for a folder nobody named.
|
|
33
|
+
*
|
|
34
|
+
* The last path segment, which for a folder of music is the album, written the
|
|
35
|
+
* way a person wrote it rather than the way a URL spells it.
|
|
36
|
+
*/
|
|
37
|
+
export function nameOfDir(url) {
|
|
38
|
+
try {
|
|
39
|
+
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
40
|
+
const last = parts[parts.length - 1];
|
|
41
|
+
return last ? decodeURIComponent(last).slice(0, 120) : new URL(url).host;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "an open directory";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function asTime(value) {
|
|
48
|
+
if (value instanceof Date)
|
|
49
|
+
return value.getTime();
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const at = Date.parse(value);
|
|
52
|
+
return Number.isNaN(at) ? 0 : at;
|
|
53
|
+
}
|
|
54
|
+
return typeof value === "number" ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
export class OpenDirs {
|
|
57
|
+
db;
|
|
58
|
+
ready = null;
|
|
59
|
+
constructor(db) {
|
|
60
|
+
this.db = db;
|
|
61
|
+
}
|
|
62
|
+
async ensure() {
|
|
63
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
64
|
+
await this.ready;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The list, newest first, in pages.
|
|
68
|
+
*
|
|
69
|
+
* Keyset on the row's own creation time rather than an offset, because a list
|
|
70
|
+
* anybody may add to shifts under a reader who is paging through it, and an
|
|
71
|
+
* offset in a shifting list repeats and skips rows.
|
|
72
|
+
*/
|
|
73
|
+
async list(limit = 50, before = 0) {
|
|
74
|
+
await this.ensure();
|
|
75
|
+
const size = Math.min(200, Math.max(1, limit));
|
|
76
|
+
const { rows } = await this.db.query(`SELECT id, url, name, added_by, tracks, created_at FROM ${TABLE}
|
|
77
|
+
${before > 0 ? "WHERE created_at < $2" : ""}
|
|
78
|
+
ORDER BY created_at DESC LIMIT $1`, before > 0 ? [size + 1, new Date(before).toISOString()] : [size + 1]);
|
|
79
|
+
// One more than asked for, so "is there another page" is a fact rather
|
|
80
|
+
// than a guess from a full one.
|
|
81
|
+
const page = rows.slice(0, size);
|
|
82
|
+
return {
|
|
83
|
+
rows: page.map((row) => ({
|
|
84
|
+
id: String(row["id"] ?? ""),
|
|
85
|
+
url: String(row["url"] ?? ""),
|
|
86
|
+
name: String(row["name"] ?? ""),
|
|
87
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
88
|
+
createdAt: asTime(row["created_at"]),
|
|
89
|
+
})),
|
|
90
|
+
addedBy: page.map((row) => String(row["added_by"] ?? "")),
|
|
91
|
+
next: rows.length > size ? asTime(page[page.length - 1]?.["created_at"]) : 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Publish one. `tracks` is what the finder's server actually saw on the page,
|
|
96
|
+
* so a row nobody can play never reaches the list.
|
|
97
|
+
*/
|
|
98
|
+
async add(userId, url, name, tracks) {
|
|
99
|
+
const clean = typeof url === "string" ? url.trim() : "";
|
|
100
|
+
// Not cleanUrl's origin-only treatment: a folder is a path, and the path is
|
|
101
|
+
// the whole point of the row.
|
|
102
|
+
if (!/^https?:\/\//i.test(clean) || cleanUrl(clean) === "")
|
|
103
|
+
return null;
|
|
104
|
+
if (tracks <= 0)
|
|
105
|
+
return null;
|
|
106
|
+
await this.ensure();
|
|
107
|
+
const { rows } = await this.db.query(`INSERT INTO ${TABLE} (id, url, name, added_by, tracks) VALUES ($1, $2, $3, $4, $5)
|
|
108
|
+
ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, tracks = EXCLUDED.tracks
|
|
109
|
+
RETURNING id, url, name, tracks, created_at`, [
|
|
110
|
+
randomBytes(8).toString("hex"),
|
|
111
|
+
clean,
|
|
112
|
+
(typeof name === "string" && name.trim() ? name.trim() : nameOfDir(clean)).slice(0, 120),
|
|
113
|
+
userId,
|
|
114
|
+
Math.min(100_000, tracks),
|
|
115
|
+
]);
|
|
116
|
+
const row = rows[0];
|
|
117
|
+
if (!row)
|
|
118
|
+
return null;
|
|
119
|
+
return {
|
|
120
|
+
id: String(row["id"] ?? ""),
|
|
121
|
+
url: String(row["url"] ?? ""),
|
|
122
|
+
name: String(row["name"] ?? ""),
|
|
123
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
124
|
+
createdAt: asTime(row["created_at"]),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Only the finder takes their own row down. */
|
|
128
|
+
async remove(userId, id) {
|
|
129
|
+
await this.ensure();
|
|
130
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 AND added_by = $2 RETURNING id`, [id, userId]);
|
|
131
|
+
return rows.length > 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Ingest } from "./ingest.ts";
|
|
|
5
5
|
import { Channels } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
7
|
import { Handles } from "./handles.ts";
|
|
8
|
+
import { OpenDirs } from "./opendirs.ts";
|
|
8
9
|
import { Servers } from "./servers.ts";
|
|
9
10
|
import { SignIn } from "./oauth.ts";
|
|
10
11
|
import { Owner } from "./owner.ts";
|
|
@@ -272,6 +273,8 @@ export interface HandlerOptions {
|
|
|
272
273
|
servers?: Servers;
|
|
273
274
|
/** The name other people see, which is never the address they signed up with. */
|
|
274
275
|
handles?: Handles;
|
|
276
|
+
/** Open directories people have found, which anyone may read. */
|
|
277
|
+
openDirs?: OpenDirs;
|
|
275
278
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
276
279
|
secureCookies?: boolean;
|
|
277
280
|
/** A certificate and key in PEM, when this server is to speak https itself. */
|
package/dist/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { Channels, cleanId } from "./channels.js";
|
|
|
23
23
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
24
24
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
25
25
|
import { anonymousHandle, Handles } from "./handles.js";
|
|
26
|
+
import { OpenDirs } from "./opendirs.js";
|
|
26
27
|
import { Servers } from "./servers.js";
|
|
27
28
|
import { DeviceGrants } from "./device.js";
|
|
28
29
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
@@ -45,7 +46,7 @@ import { extname, join, normalize, resolve, sep } from "node:path";
|
|
|
45
46
|
import { fileURLToPath } from "node:url";
|
|
46
47
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
47
48
|
import { Analyser, bandEdges, bands, decay } from "./fft.js";
|
|
48
|
-
import { loadSource, loadTagged } from "./playlist.js";
|
|
49
|
+
import { loadSource, loadTagged, readRemoteIndex } from "./playlist.js";
|
|
49
50
|
import { emptySnapshot, parseCommand, } from "./protocol.js";
|
|
50
51
|
const FFT_SIZE = 2048;
|
|
51
52
|
export const SERVE_BAND_COUNT = 24;
|
|
@@ -558,6 +559,9 @@ export function isSignInPath(path) {
|
|
|
558
559
|
path === "/api/v1/servers" ||
|
|
559
560
|
path.startsWith("/api/v1/servers/") ||
|
|
560
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/") ||
|
|
561
565
|
OAUTH_ROUTE.test(path));
|
|
562
566
|
}
|
|
563
567
|
function html(response, code, body) {
|
|
@@ -1216,6 +1220,74 @@ export function createHandler(engine, options) {
|
|
|
1216
1220
|
response.end(JSON.stringify({ account: result.account, token }));
|
|
1217
1221
|
return;
|
|
1218
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
|
+
}
|
|
1219
1291
|
// --- coming back from a provider ---------------------------------------
|
|
1220
1292
|
//
|
|
1221
1293
|
// /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
|
|
@@ -2324,7 +2396,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2324
2396
|
// all: a browser already signed in can approve a terminal.
|
|
2325
2397
|
// The same pool the follows and reminders use: three small tables in
|
|
2326
2398
|
// one database do not want three sets of connections.
|
|
2327
|
-
...(pool
|
|
2399
|
+
...(pool
|
|
2400
|
+
? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
|
|
2401
|
+
: {}),
|
|
2328
2402
|
signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
|
|
2329
2403
|
}
|
|
2330
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
CHANGED
|
@@ -35,7 +35,9 @@ const SCHEMA = `
|
|
|
35
35
|
export function cleanHandle(value: unknown): string {
|
|
36
36
|
if (typeof value !== "string") return "";
|
|
37
37
|
const wanted = value.trim().toLowerCase();
|
|
38
|
-
|
|
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 "";
|
|
39
41
|
// Doubled hyphens are how punycode marks an encoded label, so a handle with
|
|
40
42
|
// one in it can collide with an internationalised domain.
|
|
41
43
|
return wanted.includes("--") ? "" : wanted;
|
|
@@ -85,6 +87,25 @@ export class Handles {
|
|
|
85
87
|
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
86
88
|
}
|
|
87
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
|
+
|
|
88
109
|
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
89
110
|
async holder(handle: string): Promise<string> {
|
|
90
111
|
const wanted = cleanHandle(handle);
|
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
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { Channels, cleanId } from "./channels.ts";
|
|
|
30
30
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
31
31
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
32
32
|
import { anonymousHandle, Handles } from "./handles.ts";
|
|
33
|
+
import { OpenDirs } from "./opendirs.ts";
|
|
33
34
|
import { Servers } from "./servers.ts";
|
|
34
35
|
import { DeviceGrants } from "./device.ts";
|
|
35
36
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
|
|
@@ -82,7 +83,7 @@ import {
|
|
|
82
83
|
type Tools, type Track,
|
|
83
84
|
} from "./audio.ts";
|
|
84
85
|
import { Analyser, bandEdges, bands, decay } from "./fft.ts";
|
|
85
|
-
import { loadSource, loadTagged } from "./playlist.ts";
|
|
86
|
+
import { loadSource, loadTagged, readRemoteIndex } from "./playlist.ts";
|
|
86
87
|
import {
|
|
87
88
|
emptySnapshot, parseCommand,
|
|
88
89
|
type Command, type RemoteTrack, type Snapshot,
|
|
@@ -695,6 +696,9 @@ export function isSignInPath(path: string): boolean {
|
|
|
695
696
|
path === "/api/v1/servers" ||
|
|
696
697
|
path.startsWith("/api/v1/servers/") ||
|
|
697
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/") ||
|
|
698
702
|
OAUTH_ROUTE.test(path)
|
|
699
703
|
);
|
|
700
704
|
}
|
|
@@ -783,6 +787,8 @@ export interface HandlerOptions {
|
|
|
783
787
|
servers?: Servers;
|
|
784
788
|
/** The name other people see, which is never the address they signed up with. */
|
|
785
789
|
handles?: Handles;
|
|
790
|
+
/** Open directories people have found, which anyone may read. */
|
|
791
|
+
openDirs?: OpenDirs;
|
|
786
792
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
787
793
|
secureCookies?: boolean;
|
|
788
794
|
/** A certificate and key in PEM, when this server is to speak https itself. */
|
|
@@ -1495,6 +1501,79 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1495
1501
|
return;
|
|
1496
1502
|
}
|
|
1497
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
|
+
|
|
1498
1577
|
// --- coming back from a provider ---------------------------------------
|
|
1499
1578
|
//
|
|
1500
1579
|
// /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
|
|
@@ -2678,7 +2757,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2678
2757
|
// all: a browser already signed in can approve a terminal.
|
|
2679
2758
|
// The same pool the follows and reminders use: three small tables in
|
|
2680
2759
|
// one database do not want three sets of connections.
|
|
2681
|
-
...(pool
|
|
2760
|
+
...(pool
|
|
2761
|
+
? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
|
|
2762
|
+
: {}),
|
|
2682
2763
|
signIn: new SignIn(
|
|
2683
2764
|
providersFrom(process.env),
|
|
2684
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
|
+
}
|
package/web/dist/index.html
CHANGED
package/web/dist/sw.js
CHANGED