nixamp 0.7.0 → 0.7.2
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 +179 -105
- 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 +191 -110
- package/src/session.ts +100 -0
- package/web/dist/index.html +1 -0
- package/web/dist/sw.js +1 -1
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. */
|
|
@@ -1261,114 +1267,6 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1261
1267
|
return;
|
|
1262
1268
|
}
|
|
1263
1269
|
|
|
1264
|
-
// --- the name other people see ---------------------------------------
|
|
1265
|
-
//
|
|
1266
|
-
// Separate from the address on purpose. The address is a credential and
|
|
1267
|
-
// a way to reach somebody; publishing it in a directory listing or an
|
|
1268
|
-
// invite would be publishing what they log in with.
|
|
1269
|
-
if (path === "/api/v1/me/handle" && options.handles) {
|
|
1270
|
-
const handles = options.handles;
|
|
1271
|
-
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
1272
|
-
if (who === null) {
|
|
1273
|
-
json(response, 401, { error: "not signed in" });
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
if (request.method === "GET") {
|
|
1278
|
-
json(response, 200, { handle: await handles.of(who.id) });
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
if (request.method === "PUT" || request.method === "POST") {
|
|
1282
|
-
let body: { handle?: unknown };
|
|
1283
|
-
try {
|
|
1284
|
-
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1285
|
-
} catch {
|
|
1286
|
-
json(response, 400, { error: "bad JSON" });
|
|
1287
|
-
return;
|
|
1288
|
-
}
|
|
1289
|
-
const claimed = await handles.claim(who.id, body.handle);
|
|
1290
|
-
if (claimed.error) {
|
|
1291
|
-
json(response, 409, { error: claimed.error });
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
json(response, 200, { handle: claimed.handle });
|
|
1295
|
-
return;
|
|
1296
|
-
}
|
|
1297
|
-
json(response, 405, { error: "GET or PUT" });
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// --- the servers this account runs ----------------------------------
|
|
1302
|
-
//
|
|
1303
|
-
// Kept against the account rather than the machine, so the list reads the
|
|
1304
|
-
// same from the CLI, the PWA and the desktop app -- which is the whole
|
|
1305
|
-
// point: a share link in a terminal you closed is a server you have lost.
|
|
1306
|
-
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
|
|
1307
|
-
const servers = options.servers;
|
|
1308
|
-
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
1309
|
-
if (who === null) {
|
|
1310
|
-
json(response, 401, { error: "not signed in" });
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
1315
|
-
json(response, 200, { servers: await servers.list(who.id) });
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
if (path === "/api/v1/servers" && request.method === "POST") {
|
|
1320
|
-
let body: { name?: unknown; url?: unknown; key?: unknown };
|
|
1321
|
-
try {
|
|
1322
|
-
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1323
|
-
} catch {
|
|
1324
|
-
json(response, 400, { error: "bad JSON" });
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1327
|
-
const made = await servers.add(who, {
|
|
1328
|
-
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1329
|
-
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1330
|
-
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1331
|
-
});
|
|
1332
|
-
if (made === null) {
|
|
1333
|
-
json(response, 422, { error: "that needs an http or https address" });
|
|
1334
|
-
return;
|
|
1335
|
-
}
|
|
1336
|
-
json(response, 201, { server: made });
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
const id = path.slice("/api/v1/servers/".length);
|
|
1341
|
-
if (id && request.method === "DELETE") {
|
|
1342
|
-
const gone = await servers.remove(who.id, id);
|
|
1343
|
-
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
if (id && (request.method === "PATCH" || request.method === "PUT")) {
|
|
1348
|
-
let body: { name?: unknown; url?: unknown; key?: unknown };
|
|
1349
|
-
try {
|
|
1350
|
-
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1351
|
-
} catch {
|
|
1352
|
-
json(response, 400, { error: "bad JSON" });
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
const changed = await servers.update(who.id, id, {
|
|
1356
|
-
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1357
|
-
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1358
|
-
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1359
|
-
});
|
|
1360
|
-
if (changed === null) {
|
|
1361
|
-
json(response, 404, { error: "no such server, or a bad address" });
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
json(response, 200, { server: changed });
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
json(response, 405, { error: "GET, POST, PATCH or DELETE" });
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
1270
|
// --- tokens a person made on purpose --------------------------------
|
|
1373
1271
|
if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
|
|
1374
1272
|
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -1495,6 +1393,187 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1495
1393
|
return;
|
|
1496
1394
|
}
|
|
1497
1395
|
|
|
1396
|
+
// --- the name other people see ---------------------------------------
|
|
1397
|
+
//
|
|
1398
|
+
// Separate from the address on purpose. The address is a credential and
|
|
1399
|
+
// a way to reach somebody; publishing it in a directory listing or an
|
|
1400
|
+
// invite would be publishing what they log in with.
|
|
1401
|
+
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
1402
|
+
const handles = options.handles;
|
|
1403
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1404
|
+
if (who === null) {
|
|
1405
|
+
json(response, 401, { error: "not signed in" });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (request.method === "GET") {
|
|
1410
|
+
json(response, 200, { handle: await handles.of(who.id) });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
1414
|
+
let body: { handle?: unknown };
|
|
1415
|
+
try {
|
|
1416
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1417
|
+
} catch {
|
|
1418
|
+
json(response, 400, { error: "bad JSON" });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const claimed = await handles.claim(who.id, body.handle);
|
|
1422
|
+
if (claimed.error) {
|
|
1423
|
+
json(response, 409, { error: claimed.error });
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
json(response, 200, { handle: claimed.handle });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
json(response, 405, { error: "GET or PUT" });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// --- the servers this account runs ----------------------------------
|
|
1434
|
+
//
|
|
1435
|
+
// Kept against the account rather than the machine, so the list reads the
|
|
1436
|
+
// same from the CLI, the PWA and the desktop app -- which is the whole
|
|
1437
|
+
// point: a share link in a terminal you closed is a server you have lost.
|
|
1438
|
+
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
|
|
1439
|
+
const servers = options.servers;
|
|
1440
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1441
|
+
if (who === null) {
|
|
1442
|
+
json(response, 401, { error: "not signed in" });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
1447
|
+
json(response, 200, { servers: await servers.list(who.id) });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (path === "/api/v1/servers" && request.method === "POST") {
|
|
1452
|
+
let body: { name?: unknown; url?: unknown; key?: unknown };
|
|
1453
|
+
try {
|
|
1454
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1455
|
+
} catch {
|
|
1456
|
+
json(response, 400, { error: "bad JSON" });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const made = await servers.add(who, {
|
|
1460
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1461
|
+
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1462
|
+
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1463
|
+
});
|
|
1464
|
+
if (made === null) {
|
|
1465
|
+
json(response, 422, { error: "that needs an http or https address" });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
json(response, 201, { server: made });
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const id = path.slice("/api/v1/servers/".length);
|
|
1473
|
+
if (id && request.method === "DELETE") {
|
|
1474
|
+
const gone = await servers.remove(who.id, id);
|
|
1475
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (id && (request.method === "PATCH" || request.method === "PUT")) {
|
|
1480
|
+
let body: { name?: unknown; url?: unknown; key?: unknown };
|
|
1481
|
+
try {
|
|
1482
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1483
|
+
} catch {
|
|
1484
|
+
json(response, 400, { error: "bad JSON" });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const changed = await servers.update(who.id, id, {
|
|
1488
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1489
|
+
...(typeof body.url === "string" ? { url: body.url } : {}),
|
|
1490
|
+
...(typeof body.key === "string" ? { key: body.key } : {}),
|
|
1491
|
+
});
|
|
1492
|
+
if (changed === null) {
|
|
1493
|
+
json(response, 404, { error: "no such server, or a bad address" });
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
json(response, 200, { server: changed });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
json(response, 405, { error: "GET, POST, PATCH or DELETE" });
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
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(),
|