dscord 0.4.0-alpha.2 → 0.4.0-alpha.4
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/NOTICE +5 -0
- package/README.md +22 -15
- package/central/README.md +144 -0
- package/central/avatars.md +76 -0
- package/central/avatars.mjs +108 -0
- package/central/directory.mjs +224 -0
- package/central/rules.mjs +51 -0
- package/central/service.mjs +365 -0
- package/central/store.mjs +416 -0
- package/central/tokens.mjs +63 -0
- package/dist/admin/assets/index-BqwnqlFx.css +1 -0
- package/dist/admin/assets/index-mXQNxGvo.js +10 -0
- package/dist/admin/index.html +2 -2
- package/dist/admin/licenses/bundled-dependencies.txt +93 -0
- package/dist/central-admin/assets/index-ByNljbpy.css +1 -0
- package/dist/central-admin/assets/index-CKu96QBX.js +9 -0
- package/dist/central-admin/assets/mark-128-C4ed4p0U.png +0 -0
- package/dist/central-admin/index.html +13 -0
- package/dist/central-admin/licenses/bundled-dependencies.txt +514 -0
- package/dist/central-admin/licenses/lucide.txt +43 -0
- package/dist/central-admin/licenses/ui-foundation.txt +336 -0
- package/dist/central-admin/theme.js +66 -0
- package/dist/web/assets/index-Cl4X2HbO.js +55 -0
- package/dist/web/assets/index-DUlpYtqe.css +1 -0
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +272 -71
- package/dsh-connect/KERNEL.md +63 -13
- package/dsh-connect/client.js +2050 -735
- package/dsh-connect/controller.mjs +403 -295
- package/dsh-connect/decision-settings.mjs +72 -26
- package/dsh-connect/index.mjs +11 -6
- package/dsh-connect/kernel-updates.mjs +243 -0
- package/dsh-connect/native-bots.mjs +1 -5
- package/dsh-connect/native-bridge.mjs +58 -57
- package/dsh-connect/native-connections.mjs +70 -27
- package/dsh-connect/native-team.mjs +3 -0
- package/dsh-connect/work-core.mjs +352 -93
- package/dsh-plugin/README.md +2 -2
- package/dsh-plugin/runtime-rules.mjs +2 -3
- package/dsh-plugin/work-files.mjs +2 -2
- package/icon.png +0 -0
- package/memory/service.mjs +25 -11
- package/memory/store.mjs +19 -7
- package/memory/views.mjs +3 -2
- package/memory//345/244/215/347/224/250/344/270/216/350/277/220/350/241/214.md +2 -2
- package/memory//345/275/223/345/211/215/344/270/212/344/270/213/346/226/207/344/270/216/345/233/236/351/241/276.md +3 -3
- package/package.json +14 -2
- package/scripts/server-init.mjs +6 -3
- package/src/accounts.mjs +58 -139
- package/src/admin-directory.mjs +69 -0
- package/src/admin.mjs +61 -1
- package/src/bot-node.mjs +165 -122
- package/src/central-trust.mjs +53 -0
- package/src/contact-book.mjs +202 -107
- package/src/conversation-view.mjs +103 -0
- package/src/core/bot-routes.mjs +7 -19
- package/src/core/node-routes.mjs +19 -1
- package/src/core/room-routes.mjs +56 -31
- package/src/core/server-page.mjs +23 -0
- package/src/core/session-routes.mjs +29 -18
- package/src/core.mjs +55 -15
- package/src/decision-presets.mjs +47 -0
- package/src/decision-provider.mjs +115 -25
- package/src/decision-state.mjs +94 -0
- package/src/front-prompt.mjs +348 -0
- package/src/governance.mjs +79 -5
- package/src/identity.mjs +27 -38
- package/src/invites.mjs +71 -0
- package/src/kernel-control.mjs +55 -9
- package/src/kernel-node-client.mjs +2 -0
- package/src/mentions.mjs +1 -1
- package/src/message-context.mjs +5 -35
- package/src/native-bindings.mjs +37 -0
- package/src/node-delivery.mjs +1 -1
- package/src/node-protocol.mjs +99 -0
- package/src/node-store.mjs +72 -0
- package/src/profile-sync.mjs +93 -0
- package/src/push.mjs +51 -14
- package/src/room-reader.mjs +55 -0
- package/src/server-update-control.mjs +41 -0
- package/src/server-update-docker.mjs +70 -0
- package/src/server-update-release.mjs +58 -0
- package/src/server-updater.mjs +323 -0
- package/vendor/ds-bot/UPSTREAM.json +4 -0
- package/vendor/ds-bot/src/host/prompt.js +4 -1
- package//346/250/241/345/236/213/351/205/215/347/275/256/344/270/216/350/277/201/347/247/273.md +52 -15
- package/dist/admin/assets/index-DXyc54Ce.css +0 -1
- package/dist/admin/assets/index-vZ7hJNEU.js +0 -10
- package/dist/web/assets/index-62nMLjE0.css +0 -1
- package/dist/web/assets/index-OaWUoxQD.js +0 -55
- package/dsh-connect/models.mjs +0 -64
- package/dsh-connect/processes.mjs +0 -119
- package/scripts/central-init.mjs +0 -39
- package/src/native-bot-catalog.mjs +0 -75
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { failure } from './store.mjs';
|
|
3
|
+
|
|
4
|
+
export const SERVER_WELL_KNOWN = '/.well-known/dscord/server';
|
|
5
|
+
/** A server whose public page has not been read for this long drops out of search until it is read again. */
|
|
6
|
+
export const LISTING_STALE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
8
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_RESULTS = 50;
|
|
10
|
+
/** How a server can be found: by keyword search, only by its exact number, or not at all. */
|
|
11
|
+
export const DISCOVERY = ['public', 'number', 'hidden'];
|
|
12
|
+
/** How people join: anyone with a central account, or only with an invitation from the server. */
|
|
13
|
+
export const JOIN = ['open', 'invite'];
|
|
14
|
+
/** Where a listing stands with this central's administrators. Only listed servers are searchable. */
|
|
15
|
+
export const LISTING_STATUS = ['pending', 'listed', 'rejected', 'banned'];
|
|
16
|
+
/** Whether a newly submitted server waits for an administrator ('review') or is listed at once ('open'). */
|
|
17
|
+
export const LISTING_MODES = ['review', 'open'];
|
|
18
|
+
// Server numbers are eight digits, handed out at random so they say nothing about when a server was listed.
|
|
19
|
+
const NUMBER_MIN = 10_000_000;
|
|
20
|
+
const NUMBER_SPAN = 90_000_000;
|
|
21
|
+
|
|
22
|
+
const iso = value => value === null || value === undefined ? null : new Date(value).toISOString();
|
|
23
|
+
|
|
24
|
+
function text(value, max, field) {
|
|
25
|
+
if (value === undefined || value === null) return '';
|
|
26
|
+
if (typeof value !== 'string' || value.length > max || /[\u0000-\u0008\u000b-\u001f\u007f]/.test(value)) throw failure(502, 'SERVER_PAGE_INVALID', `${field} is not valid`);
|
|
27
|
+
return value.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function count(value) {
|
|
31
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The public page a chat server serves at /.well-known/dscord/server, reduced to what the index keeps. */
|
|
35
|
+
export function readServerPage(value, origin) {
|
|
36
|
+
if (!value || typeof value !== 'object' || value.schemaVersion !== 1) throw failure(502, 'SERVER_PAGE_INVALID', 'Unknown server page version');
|
|
37
|
+
if (value.origin !== origin) throw failure(502, 'SERVER_PAGE_INVALID', 'The page names a different origin');
|
|
38
|
+
const tags = Array.isArray(value.tags) ? value.tags : [];
|
|
39
|
+
if (tags.length > 10) throw failure(502, 'SERVER_PAGE_INVALID', 'At most 10 tags');
|
|
40
|
+
return {
|
|
41
|
+
central: typeof value.central === 'string' ? value.central : '',
|
|
42
|
+
discovery: DISCOVERY.includes(value.discovery) ? value.discovery : 'hidden',
|
|
43
|
+
page: {
|
|
44
|
+
name: text(value.name, 80, 'name') || new URL(origin).hostname,
|
|
45
|
+
description: text(value.description, 500, 'description'),
|
|
46
|
+
tags: [...new Set(tags.map(tag => text(tag, 32, 'tag').toLowerCase()).filter(Boolean))],
|
|
47
|
+
members: { people: count(value.members?.people), bots: count(value.members?.bots) },
|
|
48
|
+
join: JOIN.includes(value.join) ? value.join : 'invite',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJson(response) {
|
|
54
|
+
const reader = response.body?.getReader();
|
|
55
|
+
if (!reader) return response.json();
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let size = 0;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
size += value.byteLength;
|
|
62
|
+
if (size > MAX_BODY_BYTES) { await reader.cancel(); throw failure(502, 'SERVER_PAGE_INVALID', 'The server page is too large'); }
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The server index. A chat server submits its origin; the index reads the server's public page from that origin and
|
|
70
|
+
* keeps reading it. A page served at the origin and naming this central is the server's consent to be listed, so
|
|
71
|
+
* submitting needs no account. This central's administrators decide what is listed: new servers wait for review
|
|
72
|
+
* unless the central lists them at once.
|
|
73
|
+
*/
|
|
74
|
+
export class ServerDirectory {
|
|
75
|
+
constructor({ db, issuer, now = Date.now, fetch = globalThis.fetch, fetchVia = {} }) {
|
|
76
|
+
Object.assign(this, { db, issuer, now, fetch, fetchVia });
|
|
77
|
+
db.exec(`CREATE TABLE IF NOT EXISTS servers (origin TEXT PRIMARY KEY, number INTEGER NOT NULL UNIQUE,
|
|
78
|
+
status TEXT NOT NULL CHECK(status IN ('pending','listed','rejected','banned')), note TEXT NOT NULL DEFAULT '',
|
|
79
|
+
discovery TEXT NOT NULL, page_json TEXT NOT NULL, submitted_at INTEGER NOT NULL, reviewed_at INTEGER,
|
|
80
|
+
fetched_at INTEGER, last_error TEXT, checked_at INTEGER NOT NULL);`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async read(origin) {
|
|
84
|
+
const base = this.fetchVia[origin] || origin;
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await this.fetch(`${base}${SERVER_WELL_KNOWN}`, { headers: { Accept: 'application/json' }, redirect: 'error',
|
|
88
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
89
|
+
} catch { throw failure(502, 'SERVER_UNREACHABLE', 'Could not reach the server page'); }
|
|
90
|
+
if (response.status === 404) throw failure(409, 'SERVER_PAGE_MISSING', 'The server does not publish a directory page');
|
|
91
|
+
if (!response.ok) throw failure(502, 'SERVER_UNREACHABLE', `The server page answered ${response.status}`);
|
|
92
|
+
let body;
|
|
93
|
+
try { body = await readJson(response); } catch (error) { throw error.code ? error : failure(502, 'SERVER_PAGE_INVALID', 'The server page is not JSON'); }
|
|
94
|
+
const page = readServerPage(body, origin);
|
|
95
|
+
if (page.central !== this.issuer) throw failure(409, 'CENTRAL_MISMATCH', 'The server trusts a different central');
|
|
96
|
+
return page;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads the page and keeps the server. A new server is pending under review and listed at once otherwise;
|
|
101
|
+
* a rejected server that submits again goes back to pending. A banned origin stays out.
|
|
102
|
+
*/
|
|
103
|
+
async submit(origin, { review = true } = {}) {
|
|
104
|
+
const existing = this.server(origin);
|
|
105
|
+
if (existing?.status === 'banned') throw failure(403, 'SERVER_BANNED', 'This central does not list the server');
|
|
106
|
+
const read = await this.read(origin);
|
|
107
|
+
const at = this.now();
|
|
108
|
+
const page = JSON.stringify(read.page);
|
|
109
|
+
if (existing) {
|
|
110
|
+
const status = existing.status === 'rejected' ? 'pending' : existing.status;
|
|
111
|
+
this.db.prepare(`UPDATE servers SET status=?, discovery=?, page_json=?, submitted_at=CASE WHEN ?<>status THEN ? ELSE submitted_at END,
|
|
112
|
+
fetched_at=?, last_error=NULL, checked_at=? WHERE origin=?`).run(status, read.discovery, page, status, at, at, at, origin);
|
|
113
|
+
} else {
|
|
114
|
+
// A server keeps its number for as long as it stays in the index.
|
|
115
|
+
for (;;) {
|
|
116
|
+
const number = NUMBER_MIN + randomInt(NUMBER_SPAN);
|
|
117
|
+
try {
|
|
118
|
+
this.db.prepare(`INSERT INTO servers(origin,number,status,discovery,page_json,submitted_at,reviewed_at,fetched_at,checked_at)
|
|
119
|
+
VALUES (?,?,?,?,?,?,?,?,?)`).run(origin, number, review ? 'pending' : 'listed', read.discovery, page, at, review ? null : at, at, at);
|
|
120
|
+
break;
|
|
121
|
+
} catch (error) { if (!String(error.message).includes('UNIQUE')) throw error; }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return this.submittedView(this.server(origin));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
server(origin) { return this.db.prepare('SELECT * FROM servers WHERE origin=?').get(origin); }
|
|
128
|
+
|
|
129
|
+
existing(origin) {
|
|
130
|
+
const row = this.server(origin);
|
|
131
|
+
if (!row) throw failure(404, 'SERVER_NOT_FOUND');
|
|
132
|
+
return row;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reads the page again. A page that cannot be read keeps the last listing, which drops out of search once stale. */
|
|
136
|
+
async refresh(origin) {
|
|
137
|
+
const row = this.server(origin);
|
|
138
|
+
if (!row) return undefined;
|
|
139
|
+
const at = this.now();
|
|
140
|
+
try {
|
|
141
|
+
const read = await this.read(origin);
|
|
142
|
+
this.db.prepare('UPDATE servers SET discovery=?, page_json=?, fetched_at=?, last_error=NULL, checked_at=? WHERE origin=?')
|
|
143
|
+
.run(read.discovery, JSON.stringify(read.page), at, at, origin);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!error.code) throw error;
|
|
146
|
+
this.db.prepare('UPDATE servers SET last_error=?, checked_at=? WHERE origin=?').run(error.code, at, origin);
|
|
147
|
+
}
|
|
148
|
+
return this.server(origin);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async refreshAll() {
|
|
152
|
+
for (const { origin } of this.db.prepare("SELECT origin FROM servers WHERE status IN ('pending','listed') ORDER BY checked_at").all()) {
|
|
153
|
+
await this.refresh(origin);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** An administrator's decision: list, reject or ban a server, with an optional note the server's operators can read. */
|
|
158
|
+
decide(origin, status, note = '') {
|
|
159
|
+
if (!['listed', 'rejected', 'banned'].includes(status)) throw failure(400, 'LISTING_STATUS_INVALID');
|
|
160
|
+
if (typeof note !== 'string' || note.length > 500) throw failure(400, 'NOTE_INVALID');
|
|
161
|
+
this.existing(origin);
|
|
162
|
+
this.db.prepare('UPDATE servers SET status=?, note=?, reviewed_at=? WHERE origin=?').run(status, note.trim(), this.now(), origin);
|
|
163
|
+
return this.adminView(this.server(origin));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Drops a server from the index, number and all; a removed server may submit again and gets a new number. */
|
|
167
|
+
remove(origin) {
|
|
168
|
+
if (!this.db.prepare('DELETE FROM servers WHERE origin=?').run(origin).changes) throw failure(404, 'SERVER_NOT_FOUND');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What a submitting server learns: its number and where its listing stands. */
|
|
172
|
+
submittedView(row) {
|
|
173
|
+
return { origin: row.origin, number: row.number, status: row.status, note: row.note, discovery: row.discovery };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
adminView(row) {
|
|
177
|
+
return { ...this.submittedView(row), ...JSON.parse(row.page_json), submittedAt: iso(row.submitted_at), reviewedAt: iso(row.reviewed_at),
|
|
178
|
+
fetchedAt: iso(row.fetched_at), checkedAt: iso(row.checked_at), lastError: row.last_error };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every server in the index for its administrators, newest submissions first, optionally of one status. */
|
|
182
|
+
list({ status = '' } = {}) {
|
|
183
|
+
if (status && !LISTING_STATUS.includes(status)) throw failure(400, 'LISTING_STATUS_INVALID');
|
|
184
|
+
const rows = status ? this.db.prepare('SELECT * FROM servers WHERE status=? ORDER BY submitted_at DESC').all(status)
|
|
185
|
+
: this.db.prepare('SELECT * FROM servers ORDER BY submitted_at DESC').all();
|
|
186
|
+
return rows.map(row => this.adminView(row));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
counts() {
|
|
190
|
+
return Object.fromEntries(LISTING_STATUS.map(status => [status,
|
|
191
|
+
this.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE status=?').get(status).n]));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Listed servers read within the last three days. A query of digits finds the server with that number, unless it is hidden;
|
|
196
|
+
* otherwise only public servers are searched, and every word must appear in the name, description or tags.
|
|
197
|
+
*/
|
|
198
|
+
search({ q = '', tag = '', limit = 20 } = {}) {
|
|
199
|
+
const query = String(q).trim();
|
|
200
|
+
const fresh = this.now() - LISTING_STALE_MS;
|
|
201
|
+
if (/^\d{8}$/.test(query)) {
|
|
202
|
+
const row = this.db.prepare("SELECT * FROM servers WHERE number=? AND status='listed' AND discovery IN ('public','number') AND fetched_at>=?")
|
|
203
|
+
.get(Number(query), fresh);
|
|
204
|
+
if (row) return [this.publicView(row, JSON.parse(row.page_json))];
|
|
205
|
+
}
|
|
206
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
207
|
+
const wanted = String(tag).trim().toLowerCase();
|
|
208
|
+
const size = Math.min(Math.max(Number.parseInt(limit, 10) || 20, 1), MAX_RESULTS);
|
|
209
|
+
return this.db.prepare("SELECT * FROM servers WHERE status='listed' AND discovery='public' AND fetched_at>=?").all(fresh)
|
|
210
|
+
.map(row => ({ row, page: JSON.parse(row.page_json) }))
|
|
211
|
+
.filter(({ page }) => {
|
|
212
|
+
if (wanted && !page.tags.includes(wanted)) return false;
|
|
213
|
+
const haystack = [page.name, page.description, ...page.tags].join('\n').toLowerCase();
|
|
214
|
+
return words.every(word => haystack.includes(word));
|
|
215
|
+
})
|
|
216
|
+
.sort((a, b) => b.page.members.people - a.page.members.people || a.page.name.localeCompare(b.page.name))
|
|
217
|
+
.slice(0, size)
|
|
218
|
+
.map(({ row, page }) => this.publicView(row, page));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
publicView(row, page) {
|
|
222
|
+
return { origin: row.origin, number: row.number, ...page, updatedAt: iso(row.fetched_at) };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The central server owns the account format. Chat servers and clients read the
|
|
2
|
+
// published rule; they never define their own.
|
|
3
|
+
|
|
4
|
+
// A username becomes the Matrix localpart on every chat server, so a central rule
|
|
5
|
+
// can only narrow the characters Matrix allows in a localpart.
|
|
6
|
+
export const MATRIX_LOCALPART = /^[a-z0-9._=\-/]+$/;
|
|
7
|
+
const MAX_LOCALPART = 64;
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_USERNAME_RULE = Object.freeze({
|
|
10
|
+
pattern: '^[a-z][a-z0-9._-]*$',
|
|
11
|
+
minLength: 3,
|
|
12
|
+
maxLength: 32,
|
|
13
|
+
reserved: Object.freeze(['admin', 'root', 'system', 'dscord', 'central']),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function fail(message) {
|
|
17
|
+
throw new Error(`usernameRule: ${message}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The rule from central configuration, checked once at start. */
|
|
21
|
+
export function usernameRule(input = {}) {
|
|
22
|
+
const rule = { ...DEFAULT_USERNAME_RULE, ...input };
|
|
23
|
+
if (typeof rule.pattern !== 'string' || !rule.pattern.startsWith('^') || !rule.pattern.endsWith('$')) fail('pattern must be anchored with ^ and $');
|
|
24
|
+
let expression;
|
|
25
|
+
try { expression = new RegExp(rule.pattern, 'u'); }
|
|
26
|
+
catch (error) { fail(`pattern does not compile: ${error.message}`); }
|
|
27
|
+
if (!Number.isSafeInteger(rule.minLength) || rule.minLength < 1) fail('minLength must be a positive integer');
|
|
28
|
+
if (!Number.isSafeInteger(rule.maxLength) || rule.maxLength < rule.minLength || rule.maxLength > MAX_LOCALPART) {
|
|
29
|
+
fail(`maxLength must be between minLength and ${MAX_LOCALPART}`);
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(rule.reserved) || rule.reserved.some(name => typeof name !== 'string')) fail('reserved must be a list of names');
|
|
32
|
+
const reserved = [...new Set(rule.reserved.map(name => name.toLowerCase()))].sort();
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
pattern: rule.pattern, minLength: rule.minLength, maxLength: rule.maxLength, reserved: Object.freeze(reserved),
|
|
35
|
+
/** The stored (lowercase) username, or a reason it is refused. */
|
|
36
|
+
check(value) {
|
|
37
|
+
if (typeof value !== 'string') return { error: 'USERNAME_REQUIRED' };
|
|
38
|
+
const name = value.trim().toLowerCase();
|
|
39
|
+
if (name.length < rule.minLength || name.length > rule.maxLength) return { error: 'USERNAME_LENGTH' };
|
|
40
|
+
if (!MATRIX_LOCALPART.test(name) || !expression.test(name)) return { error: 'USERNAME_CHARACTERS' };
|
|
41
|
+
if (reserved.includes(name)) return { error: 'USERNAME_RESERVED' };
|
|
42
|
+
return { username: name };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What chat servers and clients see. */
|
|
48
|
+
export function publicRule(rule) {
|
|
49
|
+
return { pattern: rule.pattern, minLength: rule.minLength, maxLength: rule.maxLength, reserved: [...rule.reserved],
|
|
50
|
+
charset: MATRIX_LOCALPART.source, caseInsensitive: true, immutable: true };
|
|
51
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { Avatars } from './avatars.mjs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { localPath, packageRoot } from '../src/paths.mjs';
|
|
8
|
+
import { readJsonBody } from '../src/http-json.mjs';
|
|
9
|
+
import { Router } from '../src/core/router.mjs';
|
|
10
|
+
import { usernameRule, publicRule } from './rules.mjs';
|
|
11
|
+
import { CentralStore, failure } from './store.mjs';
|
|
12
|
+
import { LISTING_MODES, ServerDirectory } from './directory.mjs';
|
|
13
|
+
import { IDENTITY_TOKEN_TTL_SECONDS, loadSigningKey, publicJwks, signIdentityToken } from './tokens.mjs';
|
|
14
|
+
|
|
15
|
+
const jsonHeaders = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
16
|
+
const loopback = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
17
|
+
|
|
18
|
+
function origin(value, name) {
|
|
19
|
+
let url;
|
|
20
|
+
try { url = new URL(String(value)); } catch { throw new Error(`${name} must be an absolute URL`); }
|
|
21
|
+
if (url.origin !== String(value).replace(/\/$/, '')) throw new Error(`${name} must be a bare origin without a path`);
|
|
22
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback.has(url.hostname))) throw new Error(`${name} must use HTTPS`);
|
|
23
|
+
return url.origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A chat server is named by its origin; anything else is refused before a token is issued. */
|
|
27
|
+
function audienceOf(value) {
|
|
28
|
+
try { return origin(typeof value === 'string' ? value : '', 'audience'); }
|
|
29
|
+
catch { throw failure(400, 'AUDIENCE_INVALID', 'Name the chat server by its HTTPS origin'); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function serverOrigin(value) {
|
|
33
|
+
try { return origin(typeof value === 'string' ? value : '', 'origin'); }
|
|
34
|
+
catch { throw failure(400, 'SERVER_ORIGIN_INVALID', 'Name the chat server by its HTTPS origin'); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function centralOptions(config = {}) {
|
|
38
|
+
const issuer = origin(config.issuer, 'issuer');
|
|
39
|
+
const domain = config.domain ?? new URL(issuer).hostname;
|
|
40
|
+
if (typeof domain !== 'string' || !/^[a-z0-9.-]+$/.test(domain)) throw new Error('domain must be a lowercase host name');
|
|
41
|
+
if (config.registration !== undefined && !['open', 'closed'].includes(config.registration)) throw new Error('registration must be open or closed');
|
|
42
|
+
if (config.listing !== undefined && !LISTING_MODES.includes(config.listing)) throw new Error('listing must be review or open');
|
|
43
|
+
const admins = (config.admins ?? []).map(name => String(name).trim().toLowerCase()).filter(Boolean);
|
|
44
|
+
const clientOrigins = (config.clientOrigins ?? []).map(value => origin(value, 'clientOrigins'));
|
|
45
|
+
const serverFetchUrls = Object.fromEntries(Object.entries(config.serverFetchUrls ?? {})
|
|
46
|
+
.map(([key, value]) => [origin(key, 'serverFetchUrls'), new URL(String(value)).href.replace(/\/$/, '')]));
|
|
47
|
+
return { issuer, domain, registration: config.registration ?? 'open', listing: config.listing ?? 'review', admins,
|
|
48
|
+
rule: usernameRule(config.usernameRule), clientOrigins, serverFetchUrls };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The saved configuration. On first start a deployment may seed it from the environment
|
|
53
|
+
* (DSCORD_CENTRAL_ISSUER, DSCORD_CENTRAL_REGISTRATION, DSCORD_CENTRAL_LISTING, DSCORD_CENTRAL_ADMINS, DSCORD_CENTRAL_CLIENT_ORIGINS,
|
|
54
|
+
* DSCORD_CENTRAL_SERVER_FETCH_URLS); after that the file is the configuration. Registration and listing are only the starting
|
|
55
|
+
* values: administrators change them in the panel, and the saved choice wins from then on.
|
|
56
|
+
*/
|
|
57
|
+
export async function loadConfig(dataRoot, env = process.env) {
|
|
58
|
+
const filename = env.DSCORD_CENTRAL_CONFIG_PATH || path.join(dataRoot, 'central-config.json');
|
|
59
|
+
try { return JSON.parse(await readFile(filename, 'utf8')); }
|
|
60
|
+
catch (error) { if (error.code !== 'ENOENT' || !env.DSCORD_CENTRAL_ISSUER) throw error; }
|
|
61
|
+
const list = value => (value || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
62
|
+
const config = { issuer: env.DSCORD_CENTRAL_ISSUER, registration: env.DSCORD_CENTRAL_REGISTRATION || 'open',
|
|
63
|
+
listing: env.DSCORD_CENTRAL_LISTING || 'review', admins: list(env.DSCORD_CENTRAL_ADMINS), clientOrigins: list(env.DSCORD_CENTRAL_CLIENT_ORIGINS) };
|
|
64
|
+
// origin=fetch-url pairs, for chat servers whose public origin is not reachable from where central runs.
|
|
65
|
+
const fetchUrls = list(env.DSCORD_CENTRAL_SERVER_FETCH_URLS).map(pair => pair.split('=').map(part => part.trim()));
|
|
66
|
+
if (fetchUrls.length) config.serverFetchUrls = Object.fromEntries(fetchUrls);
|
|
67
|
+
centralOptions(config);
|
|
68
|
+
await writeFile(filename, JSON.stringify(config, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
69
|
+
return config;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
const staticTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
|
75
|
+
'.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' };
|
|
76
|
+
|
|
77
|
+
export async function createCentral({ dataRoot = localPath('central'), config, now = Date.now, fetch = globalThis.fetch,
|
|
78
|
+
refreshIntervalMs = REFRESH_INTERVAL_MS, adminWebRoot = process.env.DSCORD_CENTRAL_ADMIN_WEB_DIR || path.join(packageRoot, 'dist/central-admin') } = {}) {
|
|
79
|
+
await mkdir(dataRoot, { recursive: true, mode: 0o700 });
|
|
80
|
+
config ||= await loadConfig(dataRoot);
|
|
81
|
+
const options = centralOptions(config);
|
|
82
|
+
const key = await loadSigningKey(path.join(dataRoot, 'signing-key.json'));
|
|
83
|
+
const filename = path.join(dataRoot, 'central.sqlite');
|
|
84
|
+
const store = new CentralStore({ filename, rule: options.rule, domain: options.domain, now });
|
|
85
|
+
await chmod(filename, 0o600);
|
|
86
|
+
const avatars = new Avatars({ store, dataRoot, issuer: options.issuer, config: config.avatars });
|
|
87
|
+
const directory = new ServerDirectory({ db: store.db, issuer: options.issuer, now, fetch, fetchVia: options.serverFetchUrls });
|
|
88
|
+
const jwks = publicJwks(key);
|
|
89
|
+
const settings = () => ({ registration: store.setting('registration', options.registration), listing: store.setting('listing', options.listing) });
|
|
90
|
+
const discovery = {
|
|
91
|
+
issuer: options.issuer, domain: options.domain, registration: settings().registration,
|
|
92
|
+
usernameRule: publicRule(options.rule), globalIdFormat: `{username}@${options.domain}`,
|
|
93
|
+
jwksUri: `${options.issuer}/.well-known/jwks.json`, identityTokenTtlSeconds: IDENTITY_TOKEN_TTL_SECONDS,
|
|
94
|
+
deviceVerificationUri: `${options.issuer}/device`, serverIndexUri: `${options.issuer}/api/servers`,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const router = new Router();
|
|
98
|
+
const signedIn = request => {
|
|
99
|
+
const authorization = request.headers.authorization || '';
|
|
100
|
+
return store.authenticate(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
|
|
101
|
+
};
|
|
102
|
+
const person = request => {
|
|
103
|
+
const signed = signedIn(request);
|
|
104
|
+
if (signed.account.kind !== 'human') throw failure(403, 'HUMAN_REQUIRED');
|
|
105
|
+
return signed;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const administrator = request => {
|
|
109
|
+
const signed = person(request);
|
|
110
|
+
if (!options.admins.includes(signed.account.username)) throw failure(403, 'ADMIN_REQUIRED', 'This account does not administer this central');
|
|
111
|
+
return signed;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
discovery.avatars = { maxUploadBytes: avatars.options.maxUploadBytes, sizes: avatars.options.sizes };
|
|
115
|
+
router.get('/.well-known/dscord/central', ({ send }) => send(200, discovery));
|
|
116
|
+
router.get('/.well-known/jwks.json', ({ send }) => send(200, jwks));
|
|
117
|
+
|
|
118
|
+
router.post('/api/register', async ({ body, send }) => {
|
|
119
|
+
if (settings().registration !== 'open') throw failure(403, 'REGISTRATION_CLOSED');
|
|
120
|
+
const input = await body();
|
|
121
|
+
const account = await store.createHuman(input);
|
|
122
|
+
send(201, { token: store.openSession(account, 'login', 'password'), account: store.view(account) });
|
|
123
|
+
});
|
|
124
|
+
router.post('/api/login', async ({ body, send }) => {
|
|
125
|
+
const input = await body();
|
|
126
|
+
const { token, account } = await store.login(input.username, input.password);
|
|
127
|
+
send(200, { token, account: store.view(account) });
|
|
128
|
+
});
|
|
129
|
+
router.post('/api/logout', ({ request, send }) => { store.closeSession(signedIn(request).session); send(200, { loggedOut: true }); });
|
|
130
|
+
router.get('/api/me', ({ request, send }) => send(200, { account: store.view(signedIn(request).account) }));
|
|
131
|
+
// People and Bots edit their own profile the same way; a Bot's owner can also rename it from the Bot's settings.
|
|
132
|
+
router.on('PATCH', '/api/me', async ({ request, body, send }) => {
|
|
133
|
+
const { account } = signedIn(request);
|
|
134
|
+
const input = await body();
|
|
135
|
+
send(200, { account: store.view(input.displayName === undefined ? account : store.rename(account, input.displayName)) });
|
|
136
|
+
});
|
|
137
|
+
router.delete('/api/me', ({ request, send }) => { store.closeAccount(person(request).account); send(200, { closed: true }); });
|
|
138
|
+
|
|
139
|
+
// Avatars are public identity images; edits require the account itself (a person or a Bot) or the Bot's owner.
|
|
140
|
+
router.get('/api/profiles', ({ url, response, send }) => {
|
|
141
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
142
|
+
const ids = (url.searchParams.get('ids') || '').split(',').filter(Boolean);
|
|
143
|
+
if (ids.length > 100 || ids.some(id => id.length > 256)) throw failure(400, 'PROFILE_BATCH_LIMIT');
|
|
144
|
+
send(200, { profiles: [...new Set(ids)].flatMap(id => {
|
|
145
|
+
const account = store.account(id) || (id.endsWith(`@${options.domain}`) ? store.db.prepare('SELECT * FROM accounts WHERE username=?').get(id.slice(0, -(options.domain.length + 1))) : undefined);
|
|
146
|
+
return account?.status === 'active' ? [avatars.profile(account)] : [];
|
|
147
|
+
}) });
|
|
148
|
+
});
|
|
149
|
+
router.get(/^\/avatars\/([a-f0-9]{64})\/(\d+)\.webp$/, ({ response }, id, size) => avatars.serve(response, id, size));
|
|
150
|
+
for (const route of ['/api/me/avatar', /^\/api\/bots\/([^/]+)\/avatar$/]) {
|
|
151
|
+
const target = (request, id) => id ? store.ownedBot(person(request).account, id) : signedIn(request).account;
|
|
152
|
+
router.get(route, ({ request, send }, id) => send(200, { profile: avatars.profile(target(request, id)), maxUploadBytes: avatars.options.maxUploadBytes }));
|
|
153
|
+
router.on('PUT', route, async ({ request, url, send }, id) => {
|
|
154
|
+
const account = target(request, id);
|
|
155
|
+
const version = avatars.expected(account, url.searchParams.get('version'));
|
|
156
|
+
const asset = await avatars.upload(request, url.searchParams);
|
|
157
|
+
target(request, id); // Recheck authorization after decoding and writing.
|
|
158
|
+
send(200, { profile: avatars.commit(account, version, asset) });
|
|
159
|
+
});
|
|
160
|
+
router.delete(route, ({ request, url, send }, id) => {
|
|
161
|
+
const account = target(request, id);
|
|
162
|
+
send(200, { profile: avatars.commit(account, avatars.expected(account, url.searchParams.get('version')), null) });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A person's own DSH signs in with a connection key made on a chat server's page; the key carries one of these codes.
|
|
167
|
+
router.post('/api/connect-codes', ({ request, send }) => send(201, store.startConnect(person(request).account)));
|
|
168
|
+
router.post('/api/connect-codes/redeem', async ({ body, send }) => {
|
|
169
|
+
const input = await body();
|
|
170
|
+
const label = typeof input.label === 'string' && input.label.trim() ? input.label.trim().slice(0, 80) : 'DSH';
|
|
171
|
+
const { token, account } = store.redeemConnect(input.code, label);
|
|
172
|
+
send(200, { token, account: store.view(account) });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The one way to sign in to a chat server, for people and Bots alike.
|
|
176
|
+
router.post('/api/identity-tokens', async ({ request, body, send }) => {
|
|
177
|
+
const { account } = signedIn(request);
|
|
178
|
+
const input = await body();
|
|
179
|
+
const audience = audienceOf(input.audience);
|
|
180
|
+
store.grant(account, audience, { allowNew: input.allowNew === true });
|
|
181
|
+
const view = store.view(account);
|
|
182
|
+
const issuedAt = Math.floor(now() / 1000);
|
|
183
|
+
const claims = { iss: options.issuer, sub: account.id, aud: audience, iat: issuedAt, exp: issuedAt + IDENTITY_TOKEN_TTL_SECONDS,
|
|
184
|
+
jti: randomUUID(), username: view.username, gid: view.globalId, kind: view.kind, name: view.displayName,
|
|
185
|
+
...(view.owner ? { owner: view.owner } : {}) };
|
|
186
|
+
send(200, { token: signIdentityToken(key, claims), expiresIn: IDENTITY_TOKEN_TTL_SECONDS, audience });
|
|
187
|
+
});
|
|
188
|
+
router.get('/api/grants', ({ request, send }) => send(200, { grants: store.grantsOf(signedIn(request).account) }));
|
|
189
|
+
router.delete(/^\/api\/grants\/([^/]+)$/, ({ request, send }, audience) => {
|
|
190
|
+
store.revokeGrant(signedIn(request).account, audienceOf(audience));
|
|
191
|
+
send(200, { revoked: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A Bot is set up here, from any chat server's page, and the device that runs it follows.
|
|
195
|
+
router.post('/api/bots', async ({ request, body, send }) => {
|
|
196
|
+
const { account } = person(request);
|
|
197
|
+
send(201, { bot: store.managedBot(store.createBot(account, await body(), { serverOrigin })) });
|
|
198
|
+
});
|
|
199
|
+
router.get('/api/bots', ({ request, send }) => send(200, { bots: store.botsOf(person(request).account).map(bot => store.managedBot(bot)) }));
|
|
200
|
+
router.on('PATCH', /^\/api\/bots\/([^/]+)$/, async ({ request, body, send }, botId) => {
|
|
201
|
+
const { account } = person(request);
|
|
202
|
+
send(200, { bot: store.managedBot(store.updateBot(account, store.ownedBot(account, botId), await body(), { serverOrigin })) });
|
|
203
|
+
});
|
|
204
|
+
router.delete(/^\/api\/bots\/([^/]+)$/, ({ request, send }, botId) => {
|
|
205
|
+
store.closeAccount(store.ownedBot(person(request).account, botId));
|
|
206
|
+
send(200, { closed: true });
|
|
207
|
+
});
|
|
208
|
+
router.get(/^\/api\/bots\/([^/]+)\/grants$/, ({ request, send }, botId) =>
|
|
209
|
+
send(200, { grants: store.grantsOf(store.ownedBot(person(request).account, botId)) }));
|
|
210
|
+
router.delete(/^\/api\/bots\/([^/]+)\/grants\/([^/]+)$/, ({ request, send }, botId, audience) => {
|
|
211
|
+
store.revokeGrant(store.ownedBot(person(request).account, botId), audienceOf(audience));
|
|
212
|
+
send(200, { revoked: true });
|
|
213
|
+
});
|
|
214
|
+
router.get(/^\/api\/bots\/([^/]+)\/nodes$/, ({ request, send }, botId) =>
|
|
215
|
+
send(200, { nodes: store.nodesOf(store.ownedBot(person(request).account, botId)) }));
|
|
216
|
+
router.delete(/^\/api\/bots\/([^/]+)\/nodes\/([^/]+)$/, ({ request, send }, botId, nodeId) => {
|
|
217
|
+
store.revokeNode(store.ownedBot(person(request).account, botId), nodeId);
|
|
218
|
+
send(200, { revoked: true });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The person's DSH devices. Each reports every few seconds and is told which Bots to run and how.
|
|
222
|
+
router.get('/api/devices', ({ request, send }) => send(200, { devices: store.devicesOf(person(request).account) }));
|
|
223
|
+
router.post(/^\/api\/devices\/([^/]+)\/sync$/, async ({ request, body, send }, deviceId) => {
|
|
224
|
+
const { account, session } = person(request);
|
|
225
|
+
send(200, store.syncDevice(account, session, deviceId, await body()));
|
|
226
|
+
});
|
|
227
|
+
router.delete(/^\/api\/devices\/([^/]+)$/, ({ request, send }, deviceId) => {
|
|
228
|
+
store.removeDevice(person(request).account, deviceId);
|
|
229
|
+
send(200, { removed: true });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// A Bot has no password: its owner approves a node by the code the node shows.
|
|
233
|
+
router.post('/api/device', async ({ body, send }) => {
|
|
234
|
+
const input = await body();
|
|
235
|
+
const label = typeof input.label === 'string' && input.label.trim() ? input.label.trim().slice(0, 80) : 'DSH node';
|
|
236
|
+
send(200, { ...store.startDevice(label), verificationUri: discovery.deviceVerificationUri, interval: 5 });
|
|
237
|
+
});
|
|
238
|
+
router.post('/api/device/approve', async ({ request, body, send }) => {
|
|
239
|
+
const { account } = person(request);
|
|
240
|
+
const input = await body();
|
|
241
|
+
const { bot, label } = store.approveDevice(account, input.userCode, input.botId);
|
|
242
|
+
send(200, { bot: store.view(bot), label });
|
|
243
|
+
});
|
|
244
|
+
router.post('/api/device/token', async ({ body, send }) => {
|
|
245
|
+
const { token, account } = store.redeemDevice((await body()).deviceCode);
|
|
246
|
+
send(200, { token, account: store.view(account) });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// The server index. Anyone may search it, and any chat server may submit itself; this central's administrators decide what is listed.
|
|
250
|
+
router.get('/api/servers', ({ url, response, send }) => {
|
|
251
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
252
|
+
send(200, { servers: directory.search(Object.fromEntries(url.searchParams)) });
|
|
253
|
+
});
|
|
254
|
+
router.post('/api/servers', async ({ body, send }) =>
|
|
255
|
+
send(200, { server: await directory.submit(serverOrigin((await body()).origin), { review: settings().listing === 'review' }) }));
|
|
256
|
+
|
|
257
|
+
// The administrators named in the configuration run this central from its panel at /admin.
|
|
258
|
+
router.get('/api/admin/overview', ({ request, send }) => {
|
|
259
|
+
const { account } = administrator(request);
|
|
260
|
+
send(200, { account: store.view(account), issuer: options.issuer, settings: settings(), servers: directory.counts(), accounts: store.accountCounts() });
|
|
261
|
+
});
|
|
262
|
+
router.post('/api/admin/settings', async ({ request, body, send }) => {
|
|
263
|
+
administrator(request);
|
|
264
|
+
const input = await body();
|
|
265
|
+
if (input.registration !== undefined) {
|
|
266
|
+
if (!['open', 'closed'].includes(input.registration)) throw failure(400, 'REGISTRATION_INVALID');
|
|
267
|
+
store.saveSetting('registration', input.registration);
|
|
268
|
+
}
|
|
269
|
+
if (input.listing !== undefined) {
|
|
270
|
+
if (!LISTING_MODES.includes(input.listing)) throw failure(400, 'LISTING_INVALID');
|
|
271
|
+
store.saveSetting('listing', input.listing);
|
|
272
|
+
}
|
|
273
|
+
discovery.registration = settings().registration;
|
|
274
|
+
send(200, { settings: settings() });
|
|
275
|
+
});
|
|
276
|
+
router.get('/api/admin/servers', ({ request, url, send }) => {
|
|
277
|
+
administrator(request);
|
|
278
|
+
send(200, { servers: directory.list({ status: url.searchParams.get('status') || '' }) });
|
|
279
|
+
});
|
|
280
|
+
router.post(/^\/api\/admin\/servers\/([^/]+)\/decision$/, async ({ request, body, send }, value) => {
|
|
281
|
+
administrator(request);
|
|
282
|
+
const input = await body();
|
|
283
|
+
send(200, { server: directory.decide(serverOrigin(value), input.status, input.note ?? '') });
|
|
284
|
+
});
|
|
285
|
+
router.post(/^\/api\/admin\/servers\/([^/]+)\/refresh$/, async ({ request, send }, value) => {
|
|
286
|
+
administrator(request);
|
|
287
|
+
const server = serverOrigin(value);
|
|
288
|
+
directory.existing(server);
|
|
289
|
+
send(200, { server: directory.adminView(await directory.refresh(server)) });
|
|
290
|
+
});
|
|
291
|
+
router.delete(/^\/api\/admin\/servers\/([^/]+)$/, ({ request, send }, value) => {
|
|
292
|
+
administrator(request);
|
|
293
|
+
directory.remove(serverOrigin(value));
|
|
294
|
+
send(200, { removed: true });
|
|
295
|
+
});
|
|
296
|
+
router.get('/api/admin/accounts', ({ request, url, send }) => {
|
|
297
|
+
administrator(request);
|
|
298
|
+
send(200, { accounts: store.accounts(Object.fromEntries(url.searchParams)) });
|
|
299
|
+
});
|
|
300
|
+
router.post(/^\/api\/admin\/accounts\/([^/]+)\/(disable|enable)$/, ({ request, send }, id, action) => {
|
|
301
|
+
const { account } = administrator(request);
|
|
302
|
+
if (id === account.id) throw failure(409, 'ADMIN_SELF', 'An administrator cannot disable their own account');
|
|
303
|
+
send(200, { account: store.view(store.setDisabled(id, action === 'disable')) });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// The panel itself: a static single-page app.
|
|
307
|
+
router.get(/^\/admin(\/.*)?$/, async ({ response }, rest = '/') => {
|
|
308
|
+
const relative = path.normalize(rest === '/' || !path.extname(rest) ? '/index.html' : rest);
|
|
309
|
+
let bytes;
|
|
310
|
+
try { bytes = await readFile(path.join(adminWebRoot, relative)); }
|
|
311
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'EISDIR') throw failure(404, 'NOT_FOUND', 'Not found'); throw error; }
|
|
312
|
+
response.writeHead(200, { 'Content-Type': staticTypes[path.extname(relative)] || 'application/octet-stream',
|
|
313
|
+
'Cache-Control': relative === '/index.html' ? 'no-store' : 'public, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff',
|
|
314
|
+
'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer' });
|
|
315
|
+
response.end(bytes);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
function cors(request, response) {
|
|
319
|
+
const from = request.headers.origin;
|
|
320
|
+
if (!from || !options.clientOrigins.includes(from)) return;
|
|
321
|
+
response.setHeader('Access-Control-Allow-Origin', from);
|
|
322
|
+
response.setHeader('Vary', 'Origin');
|
|
323
|
+
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
324
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function dispatch(request, response) {
|
|
328
|
+
const url = new URL(request.url, 'http://localhost');
|
|
329
|
+
const send = (status, value) => { response.writeHead(status, jsonHeaders); response.end(JSON.stringify(value)); };
|
|
330
|
+
cors(request, response);
|
|
331
|
+
if (request.method === 'OPTIONS') { response.writeHead(204, { 'Cache-Control': 'no-store' }); response.end(); return; }
|
|
332
|
+
const route = router.match(request.method, url.pathname);
|
|
333
|
+
if (!route) return send(404, { error: 'Not found', code: 'NOT_FOUND' });
|
|
334
|
+
return route.handler({ request, response, url, send, body: () => readJsonBody(request) }, ...route.params);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const server = http.createServer((request, response) => {
|
|
338
|
+
dispatch(request, response).catch(error => {
|
|
339
|
+
if (response.headersSent || response.destroyed) { response.destroy(); return; }
|
|
340
|
+
const status = error.status || 500;
|
|
341
|
+
response.writeHead(status, jsonHeaders);
|
|
342
|
+
response.end(JSON.stringify({ error: status === 500 ? 'Internal error' : error.message, code: error.code || 'REQUEST_FAILED' }));
|
|
343
|
+
if (status === 500) console.error(error);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
let refreshing = Promise.resolve();
|
|
347
|
+
const timer = refreshIntervalMs > 0 ? setInterval(() => {
|
|
348
|
+
refreshing = refreshing.then(() => directory.refreshAll()).catch(error => console.error(error));
|
|
349
|
+
}, refreshIntervalMs) : undefined;
|
|
350
|
+
timer?.unref();
|
|
351
|
+
return { server, store, avatars, directory, discovery, jwks, settings, close: async () => {
|
|
352
|
+
clearInterval(timer);
|
|
353
|
+
await new Promise(resolve => server.close(resolve));
|
|
354
|
+
await refreshing;
|
|
355
|
+
store.close();
|
|
356
|
+
} };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
360
|
+
const central = await createCentral({ dataRoot: process.env.DSCORD_CENTRAL_DATA_DIR || localPath('central') });
|
|
361
|
+
const port = Number(process.env.DSCORD_CENTRAL_PORT || 18200);
|
|
362
|
+
const host = process.env.DSCORD_CENTRAL_HOST || '127.0.0.1';
|
|
363
|
+
central.server.listen(port, host, () => console.log(`DSCord Central ready at http://${host}:${port} (issuer ${central.discovery.issuer})`));
|
|
364
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => { await central.close(); process.exit(0); });
|
|
365
|
+
}
|