fedipod-server 0.11.0 → 0.13.0
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/README.md +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// server.mjs — the DeviceAgent's listener: the https servers on both
|
|
2
|
+
// loopbacks, the pidfile, the directory door, and the retry when the port is
|
|
3
|
+
// held by that door.
|
|
4
|
+
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { Authorities } from '../../shared/guard.mjs';
|
|
11
|
+
import { rootOf } from '../home.mjs';
|
|
12
|
+
import { portFree } from '../ports.mjs';
|
|
13
|
+
import { claimDirectory, yieldDirectory } from '../../gateway/directory.mjs';
|
|
14
|
+
import { ensureTrustedTls } from '../certs.mjs';
|
|
15
|
+
import { localVersion } from '../update.mjs';
|
|
16
|
+
import { projectRoot } from './static.mjs';
|
|
17
|
+
import { secureOrigin } from './origins.mjs';
|
|
18
|
+
import { buildAdminSurface } from './surface.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
|
|
22
|
+
|
|
23
|
+
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
|
|
24
|
+
// Injectable, so a test can put a checkout ahead of the running process
|
|
25
|
+
// without editing the package.json of the machine running the test.
|
|
26
|
+
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
27
|
+
const gate = makeGate(gateToken);
|
|
28
|
+
// Live, so the named origin appears the moment connect() reads the handle
|
|
29
|
+
// out of pod state — including for the OAuth redirect check in MastoApi.
|
|
30
|
+
// The https listener's port joins the authority set: same names, second port.
|
|
31
|
+
const allowed = new Authorities(port, handle);
|
|
32
|
+
agent.authorities = allowed;
|
|
33
|
+
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
|
|
34
|
+
|
|
35
|
+
// Loopback both ways: the canonical URL is https://localhost:<port>/, and
|
|
36
|
+
// "localhost" resolves to ::1 on many systems before falling back to IPv4 —
|
|
37
|
+
// answer on both so the same origin always works (one origin = one
|
|
38
|
+
// browser storage = one login). There is one listener and it is https: the
|
|
39
|
+
// port you name is the port you browse.
|
|
40
|
+
// Every listener here is https, so a caller that did not bring a certificate
|
|
41
|
+
// gets this install's own rather than an exception: throwing here killed the
|
|
42
|
+
// signal handlers registered after the call, and the agent became unstoppable.
|
|
43
|
+
if (!tls) {
|
|
44
|
+
tls = ensureTrustedTls(path.join(rootOf(agent.home || os.tmpdir()), 'certs'),
|
|
45
|
+
{ log, names: allowed.label ? [`${allowed.label}.localhost`] : [] });
|
|
46
|
+
}
|
|
47
|
+
const tlsOpts = { key: tls.key, cert: tls.cert };
|
|
48
|
+
const server = https.createServer(tlsOpts, handler);
|
|
49
|
+
streaming.attach(server);
|
|
50
|
+
const server6 = https.createServer(tlsOpts, handler);
|
|
51
|
+
streaming.attach(server6);
|
|
52
|
+
server6.on('error', () => { /* no IPv6 loopback on this system — IPv4 covers it */ });
|
|
53
|
+
const onListen = () => {
|
|
54
|
+
// Pidfile for `fedipod stop` — written only AFTER the listen
|
|
55
|
+
// succeeds, so a port-race loser can never clobber the live agent's pid.
|
|
56
|
+
try {
|
|
57
|
+
if (agent.home) fs.writeFileSync(path.join(agent.home, 'agent.pid'), String(process.pid) + '\n');
|
|
58
|
+
} catch { /* stop will report no pidfile */ }
|
|
59
|
+
// The named origin when there is one: on a detached start this line is the
|
|
60
|
+
// only record of where to browse, and sending you to the shared origin is
|
|
61
|
+
// how two identities end up in one browser storage bucket.
|
|
62
|
+
log(`FediPod on ${secureOrigin(allowed.label, port)}/ (UI + API)`
|
|
63
|
+
+ (tls.trust ? '' : ' (self-signed — your client may ask once to trust it)'));
|
|
64
|
+
// Hold the well-known door: whoever answers on the directory port sends the
|
|
65
|
+
// browser to its own record page, which lists every identity on this
|
|
66
|
+
// machine. Only a configured agent qualifies — an unconfigured one's record
|
|
67
|
+
// page is a setup form with no identity list.
|
|
68
|
+
const door = claimDirectory({ port,
|
|
69
|
+
origin: () => secureOrigin(allowed.label, port),
|
|
70
|
+
log, eligible: () => agent.configured(), tls });
|
|
71
|
+
// Closing the IPv4 listener has to take the IPv6 one and the door with it,
|
|
72
|
+
// or a shutdown leaves a listening handle and the process never exits.
|
|
73
|
+
server.on('close', () => { door.stop(); try { server6.close(); } catch { /* already down */ } });
|
|
74
|
+
};
|
|
75
|
+
let retriedDoor = false;
|
|
76
|
+
server.on('error', async (e) => {
|
|
77
|
+
if (e.code === 'EADDRINUSE') {
|
|
78
|
+
// The directory door yields to a real owner of its port; anything else
|
|
79
|
+
// holding it is a genuine conflict.
|
|
80
|
+
if (!retriedDoor && await yieldDirectory(port, { portFree, home: agent.home })) {
|
|
81
|
+
retriedDoor = true;
|
|
82
|
+
server.listen(port, '127.0.0.1', onListen);
|
|
83
|
+
if (!server6.listening) server6.listen(port, '::1');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
log(`port ${port} is already in use by another server — set AP_PORT to a free port and retry`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
throw e;
|
|
90
|
+
});
|
|
91
|
+
server.listen(port, '127.0.0.1', onListen);
|
|
92
|
+
server6.listen(port, '::1');
|
|
93
|
+
|
|
94
|
+
return server;
|
|
95
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// static.mjs — what the agent serves off disk: the vendored client with its
|
|
2
|
+
// own service worker taken out, any client dist dropped into ui/, and the
|
|
3
|
+
// agent's own pages; the headers every response carries; and the two
|
|
4
|
+
// constants every module here shares, where the checkout is and what
|
|
5
|
+
// version it is.
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { wsOrigins } from './origins.mjs';
|
|
12
|
+
|
|
13
|
+
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
14
|
+
|
|
15
|
+
const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
|
|
16
|
+
// The vendored client is upstream's build, byte for byte, so the integrity
|
|
17
|
+
// check can say so. The two things it needs changed happen on the way out
|
|
18
|
+
// instead of in the files.
|
|
19
|
+
//
|
|
20
|
+
// vite-plugin-pwa injects this registration into every page it builds. The
|
|
21
|
+
// worker it installs answers navigations from a precache and replays the
|
|
22
|
+
// headers stored at install time, so a CSP change can never reach a browser
|
|
23
|
+
// that already has one.
|
|
24
|
+
const SW_REGISTER = /<script id="vite-plugin-pwa:inline-sw">[\s\S]*?<\/script>/i;
|
|
25
|
+
// Removing it stops new installs. This replaces the worker itself, so the ones
|
|
26
|
+
// already out there clean up: browsers re-fetch sw.js on navigation and
|
|
27
|
+
// install what they find. No fetch handler on purpose — a worker without one
|
|
28
|
+
// never intercepts a request, so pages go straight to the network while it
|
|
29
|
+
// runs.
|
|
30
|
+
const SW_KILL = `self.addEventListener('install', () => self.skipWaiting());
|
|
31
|
+
|
|
32
|
+
self.addEventListener('activate', (event) => {
|
|
33
|
+
event.waitUntil((async () => {
|
|
34
|
+
for (const key of await caches.keys()) await caches.delete(key);
|
|
35
|
+
await self.registration.unregister();
|
|
36
|
+
for (const client of await self.clients.matchAll({ type: 'window' })) {
|
|
37
|
+
try { await client.navigate(client.url); } catch { /* tab will refresh on its own */ }
|
|
38
|
+
}
|
|
39
|
+
})());
|
|
40
|
+
});
|
|
41
|
+
`;
|
|
42
|
+
// Null rather than the original when the tag is gone: an upstream change that
|
|
43
|
+
// silently no-opped here would put the worker back, which is the whole thing
|
|
44
|
+
// this exists to prevent.
|
|
45
|
+
function stripSwRegistration(html) {
|
|
46
|
+
return SW_REGISTER.test(html) ? html.replace(SW_REGISTER, '') : null;
|
|
47
|
+
}
|
|
48
|
+
const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
|
|
49
|
+
// Our own pages, kept out of ui/ for two reasons: a client dist dropped in
|
|
50
|
+
// there under the same name would shadow them, and a group serves these and
|
|
51
|
+
// nothing else — so the prefix has to be one nobody is invited to write into.
|
|
52
|
+
// One surface, /admin/, with setup as its first section: /admin/setup/ is the
|
|
53
|
+
// first run, /admin/ is the record, and there is room for the rest.
|
|
54
|
+
const WEB_DIR = path.join(projectRoot, 'web');
|
|
55
|
+
const WEB_MOUNTS = ['admin'];
|
|
56
|
+
export const webMount = (pathname) => {
|
|
57
|
+
const seg = decodeURIComponent(pathname).replace(/^\/+/, '').split('/')[0];
|
|
58
|
+
return WEB_MOUNTS.includes(seg) ? seg : null;
|
|
59
|
+
};
|
|
60
|
+
export const SETUP_PAGE = '/admin/setup/';
|
|
61
|
+
|
|
62
|
+
export const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version;
|
|
63
|
+
|
|
64
|
+
const MIME = {
|
|
65
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
|
|
66
|
+
'.json': 'application/json', '.map': 'application/json', '.webmanifest': 'application/manifest+json',
|
|
67
|
+
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.gif': 'image/gif',
|
|
68
|
+
'.jpg': 'image/jpeg', '.webp': 'image/webp', '.txt': 'text/plain', '.woff2': 'font/woff2',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Sent on every response: nosniff and no-referrer everywhere, and for HTML a
|
|
72
|
+
// CSP that keeps SCRIPTS to our own origin while still allowing the remote
|
|
73
|
+
// avatars, media and instance calls a fediverse client must make. It is not an
|
|
74
|
+
// exfiltration boundary — connect-src has to allow https: for the client to
|
|
75
|
+
// work at all — it is a code-execution one.
|
|
76
|
+
// Phanpy's index.html carries an inline bootstrap script. Rather than
|
|
77
|
+
// opening the policy with 'unsafe-inline', hash the inline scripts we
|
|
78
|
+
// actually ship and allow exactly those.
|
|
79
|
+
let inlineHashes = null;
|
|
80
|
+
function inlineScriptHashes() {
|
|
81
|
+
if (inlineHashes) return inlineHashes;
|
|
82
|
+
inlineHashes = [];
|
|
83
|
+
try {
|
|
84
|
+
// The page as it is SERVED, not as it sits on disk: hashing the
|
|
85
|
+
// registration script we strip would allow a script nobody gets.
|
|
86
|
+
const raw = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
|
|
87
|
+
const html = stripSwRegistration(raw) ?? raw;
|
|
88
|
+
for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
89
|
+
const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
|
|
90
|
+
inlineHashes.push(`'sha256-${digest}'`);
|
|
91
|
+
}
|
|
92
|
+
} catch { /* no inline scripts to allow */ }
|
|
93
|
+
return inlineHashes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function securityHeaders(auth, isHtml) {
|
|
97
|
+
const h = {
|
|
98
|
+
'x-content-type-options': 'nosniff',
|
|
99
|
+
'referrer-policy': 'no-referrer',
|
|
100
|
+
// SAMEORIGIN, not DENY: /admin/client/ frames the bundled client so a bar
|
|
101
|
+
// of ours can sit above it. Only pages on this agent's own origins may —
|
|
102
|
+
// the same set the Host/Origin firewall already trusts.
|
|
103
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
104
|
+
};
|
|
105
|
+
if (isHtml) {
|
|
106
|
+
h['content-security-policy'] = [
|
|
107
|
+
"default-src 'self'",
|
|
108
|
+
`script-src 'self' 'wasm-unsafe-eval' ${inlineScriptHashes().join(' ')}`,
|
|
109
|
+
"style-src 'self' 'unsafe-inline'",
|
|
110
|
+
"img-src 'self' https: data: blob:",
|
|
111
|
+
"media-src 'self' https: data: blob:",
|
|
112
|
+
"font-src 'self' data:",
|
|
113
|
+
// Every authority the Host/Origin firewall accepts, so browsing an agent
|
|
114
|
+
// at its own name (solo.localhost:8041, a tailnet host) keeps streaming.
|
|
115
|
+
// Pinning this to localhost blocked the socket with no visible error.
|
|
116
|
+
//
|
|
117
|
+
// `https:` is still here and the comment above no longer claims otherwise:
|
|
118
|
+
// a fediverse client fetches remote instances by design — link previews,
|
|
119
|
+
// an actor's own server, media — so there is no narrower set that leaves
|
|
120
|
+
// it working. The XSS story is `script-src 'self'` plus hashes; treat
|
|
121
|
+
// connect-src as availability, not containment.
|
|
122
|
+
`connect-src 'self' https: ${(typeof auth.wsAuthorities === 'function'
|
|
123
|
+
? auth.wsAuthorities()
|
|
124
|
+
: wsOrigins(auth.port, auth.labels())).join(' ')}`,
|
|
125
|
+
"frame-ancestors 'self'",
|
|
126
|
+
"base-uri 'self'",
|
|
127
|
+
"form-action 'self'",
|
|
128
|
+
].join('; ');
|
|
129
|
+
}
|
|
130
|
+
return h;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function sendJson(res, status, obj, auth) {
|
|
134
|
+
res.writeHead(status, { 'content-type': 'application/json', ...securityHeaders(auth, false) });
|
|
135
|
+
res.end(JSON.stringify(obj) + '\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Static UI serving, path-jailed. Phanpy owns the root; any other client
|
|
139
|
+
// dist dropped into ui/<name>/ is served at /<name>/. Hash-routed apps:
|
|
140
|
+
// '/' (and directories) get their index.html; anything unknown 404s.
|
|
141
|
+
export function serveStatic(res, pathname, auth) {
|
|
142
|
+
let rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
143
|
+
let baseDir = PHANPY_DIR;
|
|
144
|
+
const uiName = rel.split('/')[0];
|
|
145
|
+
// The mount name is joined to UI_DIR, so it must be CONTAINED by it — the
|
|
146
|
+
// same resolve-then-check sendFile does below, for the same reason.
|
|
147
|
+
//
|
|
148
|
+
// Decoding happens before the split, so a `%2f` in the first segment becomes
|
|
149
|
+
// a real separator afterwards and `..` arrives here as a mount name. It
|
|
150
|
+
// exists, and it is a directory, so baseDir was silently re-based to the
|
|
151
|
+
// project root — and sendFile's jail then enforced containment against THAT,
|
|
152
|
+
// dutifully approving `/..%2fpackage.json`, `/..%2f.git/config` and every
|
|
153
|
+
// source file under it for anyone who could reach the port.
|
|
154
|
+
// Resolved through symlinks, not just lexically: sendFile's own jail has
|
|
155
|
+
// always used realpath, and a lexical check here would still admit a mount
|
|
156
|
+
// that is a link pointing out of ui/.
|
|
157
|
+
let mount = '';
|
|
158
|
+
try {
|
|
159
|
+
if (uiName) {
|
|
160
|
+
const cand = path.resolve(UI_DIR, uiName);
|
|
161
|
+
if (cand.startsWith(UI_DIR + path.sep) && fs.statSync(cand).isDirectory()) {
|
|
162
|
+
const real = fs.realpathSync(cand);
|
|
163
|
+
if (real.startsWith(fs.realpathSync(UI_DIR) + path.sep)) mount = cand;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch { /* no such mount; fall through to the default base */ }
|
|
167
|
+
if (mount) {
|
|
168
|
+
baseDir = mount;
|
|
169
|
+
rel = rel.slice(uiName.length).replace(/^\/+/, '');
|
|
170
|
+
}
|
|
171
|
+
return sendFile(res, baseDir, rel, auth);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// '/admin/setup' names a directory, so it needs the slash the browser will
|
|
175
|
+
// resolve relative URLs against. Returns the corrected path, or null.
|
|
176
|
+
export function webDirRedirect(pathname) {
|
|
177
|
+
if (pathname.endsWith('/')) return null;
|
|
178
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
179
|
+
const full = path.normalize(path.join(WEB_DIR, rel));
|
|
180
|
+
if (!full.startsWith(WEB_DIR + path.sep)) return null; // not ours to stat
|
|
181
|
+
try {
|
|
182
|
+
if (fs.statSync(full).isDirectory()) return pathname + '/';
|
|
183
|
+
} catch { /* not a directory here */ }
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// web/<mount>/ → /<mount>/. Same jail, different mount rule.
|
|
188
|
+
export function serveWeb(res, pathname, mount, auth) {
|
|
189
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '').slice(mount.length).replace(/^\/+/, '');
|
|
190
|
+
return sendFile(res, path.join(WEB_DIR, mount), rel, auth);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sendFile(res, baseDir, rel, auth) {
|
|
194
|
+
if (rel === '' || rel.endsWith('/')) rel += 'index.html';
|
|
195
|
+
const file = path.normalize(path.join(baseDir, rel));
|
|
196
|
+
if (!file.startsWith(baseDir + path.sep) && file !== path.join(baseDir, 'index.html')) {
|
|
197
|
+
res.writeHead(403); res.end(); return true;
|
|
198
|
+
}
|
|
199
|
+
let target = file;
|
|
200
|
+
try {
|
|
201
|
+
if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
|
202
|
+
// Resolve symlinks before reading: a link inside a UI dir must not be a
|
|
203
|
+
// way out of the jail.
|
|
204
|
+
const real = fs.realpathSync(target);
|
|
205
|
+
const realBase = fs.realpathSync(baseDir);
|
|
206
|
+
if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) {
|
|
207
|
+
res.writeHead(403); res.end(); return true;
|
|
208
|
+
}
|
|
209
|
+
const ext = path.extname(real);
|
|
210
|
+
let body = fs.readFileSync(real);
|
|
211
|
+
// The vendored client, adjusted on the way out rather than in the files.
|
|
212
|
+
if (real.startsWith(fs.realpathSync(PHANPY_DIR))) {
|
|
213
|
+
if (path.basename(real) === 'sw.js') {
|
|
214
|
+
body = Buffer.from(SW_KILL);
|
|
215
|
+
} else if (ext === '.html') {
|
|
216
|
+
const stripped = stripSwRegistration(body.toString('utf8'));
|
|
217
|
+
if (stripped === null) {
|
|
218
|
+
console.error(`refusing to serve ${path.basename(real)}: no service-worker `
|
|
219
|
+
+ 'registration to remove — the vendored client changed shape');
|
|
220
|
+
res.writeHead(500, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
|
|
221
|
+
res.end('the bundled client changed shape; refusing to serve it\n');
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
body = Buffer.from(stripped);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Our own pages are read straight off disk and change whenever the project
|
|
228
|
+
// does. With no cache headers a browser is free to reuse them without
|
|
229
|
+
// asking, so an edited page keeps rendering the old one and looks like the
|
|
230
|
+
// edit never landed. The vendored client dists have hashed filenames and
|
|
231
|
+
// are left alone.
|
|
232
|
+
const ours = real.startsWith(fs.realpathSync(WEB_DIR) + path.sep);
|
|
233
|
+
res.writeHead(200, {
|
|
234
|
+
'content-type': MIME[ext] || 'application/octet-stream',
|
|
235
|
+
...(ours ? { 'cache-control': 'no-store' } : {}),
|
|
236
|
+
...securityHeaders(auth, ext === '.html'),
|
|
237
|
+
});
|
|
238
|
+
res.end(body);
|
|
239
|
+
} catch {
|
|
240
|
+
res.writeHead(404, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
|
|
241
|
+
res.end('not found\n');
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// surface.mjs — the whole admin surface as one request handler, with
|
|
2
|
+
// nothing that belongs to a process of its own: the preamble every request
|
|
3
|
+
// passes (CORS for the client API, the Host/Origin firewall, the operator's
|
|
4
|
+
// door when embedded), the protocol routes that answer strangers (C2S, the
|
|
5
|
+
// OAuth metadata, nodeinfo, the Mastodon facade), the pages and client
|
|
6
|
+
// served off disk, and the dispatch to the routes modules beside it. Each of
|
|
7
|
+
// those exports get() and post() over the same ctx.
|
|
8
|
+
|
|
9
|
+
import { MastoApi } from '../../client/masto/index.mjs';
|
|
10
|
+
import { C2S } from '../../client/c2s.mjs';
|
|
11
|
+
import { makeC2sAuth } from '../../client/oidc-auth.mjs';
|
|
12
|
+
import { Streaming } from '../../client/streaming.mjs';
|
|
13
|
+
import { nodeinfoPointer, nodeinfoDoc } from '../../core/wire.mjs';
|
|
14
|
+
import { checkRequest } from '../../shared/guard.mjs';
|
|
15
|
+
import { hasCredential } from '../setup.mjs';
|
|
16
|
+
import { localVersion } from '../update.mjs';
|
|
17
|
+
import { projectRoot, AGENT_VERSION, SETUP_PAGE, securityHeaders, sendJson, serveStatic, serveWeb, webDirRedirect, webMount } from './static.mjs';
|
|
18
|
+
import * as owner from './routes/owner.mjs';
|
|
19
|
+
import * as setup from './routes/setup.mjs';
|
|
20
|
+
import * as lifecycle from './routes/lifecycle.mjs';
|
|
21
|
+
import * as gateway from './routes/gateway.mjs';
|
|
22
|
+
import * as social from './routes/social.mjs';
|
|
23
|
+
import * as connections from './routes/connections.mjs';
|
|
24
|
+
|
|
25
|
+
// POSTs an unconfigured agent still answers: /block is worth having before
|
|
26
|
+
// federation starts, and /setup is how it stops being unconfigured.
|
|
27
|
+
// /shutdown is here because stopping an agent that was never set up is exactly
|
|
28
|
+
// the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
|
|
29
|
+
// only to this machine.
|
|
30
|
+
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
|
|
31
|
+
// Routes that manage a local agent process — spawning siblings, killing this
|
|
32
|
+
// one, moving files on the machine, running setup in a browser. Inside a pod
|
|
33
|
+
// server there is no such process and no such machine: identities come from
|
|
34
|
+
// the server's own configuration, so these are not there to be found.
|
|
35
|
+
const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
|
|
36
|
+
'/state-move', '/setup', '/setup/check', '/setup/reset']);
|
|
37
|
+
// AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
|
|
38
|
+
// fediverse is welcome there; creating accounts and editing the record is for
|
|
39
|
+
// whoever is sitting at the machine.
|
|
40
|
+
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/setup/reset', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
|
|
41
|
+
|
|
42
|
+
// A cross-origin form POST needs no CORS preflight, and JSON.parse does not
|
|
43
|
+
// care what Content-Type claimed — so parsing whatever arrived let a visited
|
|
44
|
+
// page reach every write route with a body of its choosing. Our own callers
|
|
45
|
+
// (web/admin/*.js, the CLI) all send application/json; `stop` sends no body at
|
|
46
|
+
// all, which is why an absent type is allowed only for an empty one.
|
|
47
|
+
function readBody(req) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
50
|
+
const isJson = ct === 'application/json';
|
|
51
|
+
const wrongType = () => reject(new Error('expected content-type: application/json'));
|
|
52
|
+
if (ct && !isJson) { req.resume(); wrongType(); return; }
|
|
53
|
+
let data = '';
|
|
54
|
+
req.on('data', c => {
|
|
55
|
+
data += c;
|
|
56
|
+
if (data.length > 1e6) { reject(new Error('request body too large')); req.destroy(); }
|
|
57
|
+
});
|
|
58
|
+
req.on('end', () => {
|
|
59
|
+
if (data && !isJson) return wrongType();
|
|
60
|
+
try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
|
|
61
|
+
});
|
|
62
|
+
req.on('error', reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The routes, in the order the one if-chain and the one switch used to be
|
|
67
|
+
// read: the first module that answers a path wins.
|
|
68
|
+
const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
|
|
69
|
+
|
|
70
|
+
// The whole admin/facade surface as one request handler, with nothing that
|
|
71
|
+
// belongs to a process of its own. startAdmin wraps it in listeners; the CSS
|
|
72
|
+
// component hands it CSS's, so the same routes answer on the pod's origin.
|
|
73
|
+
//
|
|
74
|
+
// `embedded` cuts the routes that manage local agent processes — they have no
|
|
75
|
+
// meaning inside a server that is not one — and moves the gate: a pod that is
|
|
76
|
+
// a fediverse instance must let strangers reach /api and /oauth, so the gate
|
|
77
|
+
// guards the operator's door (basePath) instead of the whole surface.
|
|
78
|
+
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
79
|
+
port = null, handle = null, embedded = false, basePath = '/',
|
|
80
|
+
publicOrigin = null, scheme = null,
|
|
81
|
+
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
82
|
+
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
83
|
+
const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
|
|
84
|
+
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
85
|
+
// the facade's token, so the two surfaces share one notion of the operator.
|
|
86
|
+
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
|
|
87
|
+
const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
|
|
88
|
+
// Asked per request, not once here: startAdmin runs before connect, so the
|
|
89
|
+
// kind is not known yet at mount time.
|
|
90
|
+
const isGroup = () => agent.store.getConfig()?.kind === 'group';
|
|
91
|
+
// One setup at a time, and the record outlives the run: a reloaded page
|
|
92
|
+
// must still find out how the run it started ended. `setup.run` is written
|
|
93
|
+
// by the setup routes and read by the setup page's state route.
|
|
94
|
+
const setup_ = { run: null };
|
|
95
|
+
// New statuses/notifications flow to connected streaming clients live.
|
|
96
|
+
agent.store.onEvent = (type, obj) => {
|
|
97
|
+
try {
|
|
98
|
+
if (type === 'status') streaming.broadcast('update', masto.status(obj));
|
|
99
|
+
else if (type === 'notification') {
|
|
100
|
+
streaming.broadcast('notification', masto.notification(obj));
|
|
101
|
+
// and out to any closed client, via its push subscription
|
|
102
|
+
masto.pushNotify(obj).catch(e => log(`webpush: ${e.message}`));
|
|
103
|
+
}
|
|
104
|
+
} catch (e) { log(`streaming broadcast: ${e.message}`); }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// A path as the browser must ask for it: behind the door, prefixed with it.
|
|
108
|
+
const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
|
|
109
|
+
|
|
110
|
+
// What every route may reach: the agent and the deployment's facts.
|
|
111
|
+
const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
|
|
112
|
+
|
|
113
|
+
const handler = async (req, res) => {
|
|
114
|
+
const url = new URL(req.url, 'http://localhost');
|
|
115
|
+
// Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
|
|
116
|
+
// routes answer any origin — a browser client is served the way any
|
|
117
|
+
// instance serves it. CORS headers and the preflight make that work; the
|
|
118
|
+
// bearer stays the only credential, and the Host check below (which is
|
|
119
|
+
// what stops DNS rebinding) still runs.
|
|
120
|
+
const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
|
|
121
|
+
|| url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
|
|
122
|
+
if (apiPath) {
|
|
123
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
124
|
+
res.setHeader('access-control-expose-headers', 'Link');
|
|
125
|
+
if (req.method === 'OPTIONS') {
|
|
126
|
+
res.writeHead(204, {
|
|
127
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
128
|
+
'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
|
|
129
|
+
'access-control-max-age': '86400',
|
|
130
|
+
});
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Host/Origin firewall: loopback binding alone does not keep a visited web
|
|
136
|
+
// page (or a rebound DNS name) out. The API paths keep the Host check but
|
|
137
|
+
// answer a foreign Origin, per above.
|
|
138
|
+
const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
|
|
139
|
+
if (bad) {
|
|
140
|
+
log(`refused: ${bad} (${req.method} ${req.url})`);
|
|
141
|
+
res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
|
|
142
|
+
res.end('forbidden\n');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let p = url.pathname;
|
|
146
|
+
// Embedded, the operator's door is one path on the pod's origin. Behind it
|
|
147
|
+
// is everything that was the admin server; in front of it are the protocol
|
|
148
|
+
// routes, which have to answer strangers because that is what makes the pod
|
|
149
|
+
// an instance other software can talk to.
|
|
150
|
+
let atDoor = !embedded;
|
|
151
|
+
if (embedded && basePath !== '/'
|
|
152
|
+
&& (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
|
|
153
|
+
atDoor = true;
|
|
154
|
+
p = p.slice(basePath.length - 1) || '/';
|
|
155
|
+
}
|
|
156
|
+
if (embedded && atDoor && EMBEDDED_CUT.has(p)) {
|
|
157
|
+
return json(res, 404, { error: 'not available on a server-hosted identity' });
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
// C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
|
|
161
|
+
// DPoP proof or the facade's bearer — so the dk-token gate does not
|
|
162
|
+
// stand in front of it. The Host/Origin firewall above still does.
|
|
163
|
+
if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
|
|
164
|
+
if (await c2s.handle(req, res, p, url)) return;
|
|
165
|
+
}
|
|
166
|
+
// Where a client looks first to find out how to sign in (RFC 8414), and
|
|
167
|
+
// in front of the door for the same reason C2S is: a client that has to
|
|
168
|
+
// be handed a secret before it can ask how to sign in cannot set itself
|
|
169
|
+
// up at all. It names endpoints and nothing else, the endpoints it names
|
|
170
|
+
// refuse without a password anyway, and the host and origin firewall
|
|
171
|
+
// above still decides who gets this far.
|
|
172
|
+
if (p === '/.well-known/oauth-authorization-server') {
|
|
173
|
+
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
174
|
+
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
|
|
175
|
+
}
|
|
176
|
+
if (atDoor && gate(req, res)) return;
|
|
177
|
+
if (p === '/api/v1/streaming/health') {
|
|
178
|
+
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
|
179
|
+
}
|
|
180
|
+
// NodeInfo on the agent origin — clients probe it at login.
|
|
181
|
+
if (p === '/.well-known/nodeinfo') {
|
|
182
|
+
return json(res, 200, nodeinfoPointer(
|
|
183
|
+
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
|
|
184
|
+
}
|
|
185
|
+
if (p === '/nodeinfo/2.0') {
|
|
186
|
+
return json(res, 200, nodeinfoDoc({
|
|
187
|
+
version: AGENT_VERSION,
|
|
188
|
+
localPosts: agent.store.countStatuses('post'),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
// A group serves the client too, as of 2026-08-01. It was withheld on the
|
|
192
|
+
// reasoning that a group has no timeline a human reads — which was wrong
|
|
193
|
+
// twice over: a group has statuses (what it carried) and notifications
|
|
194
|
+
// (who joined), and its operator has a bio to edit and a profile they
|
|
195
|
+
// want to see the way everyone else does. The surface this opens is a
|
|
196
|
+
// login and client tokens, and `passwd` has never had a group carve-out,
|
|
197
|
+
// so a group can be gated exactly like a person before it is exposed.
|
|
198
|
+
if (p.startsWith('/api/') || p.startsWith('/oauth/')) {
|
|
199
|
+
if (await masto.handle(req, res, p, url)) return;
|
|
200
|
+
}
|
|
201
|
+
if (embedded && !atDoor) return json(res, 404, { error: 'unknown endpoint' });
|
|
202
|
+
if (req.method === 'GET') {
|
|
203
|
+
for (const r of ROUTES) if (await r.get?.(p, ctx, req, res, url)) return;
|
|
204
|
+
}
|
|
205
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
206
|
+
// Our own pages come before the group check: a group is set up in the
|
|
207
|
+
// browser like anything else, and it has a record to edit. It still
|
|
208
|
+
// serves no fediverse client — see the 404 two lines down.
|
|
209
|
+
const mount = webMount(p);
|
|
210
|
+
if (mount) {
|
|
211
|
+
// Without the slash a page's own relative <script src> resolves one
|
|
212
|
+
// level up and 404s — and that is true at any depth, so ask the
|
|
213
|
+
// filesystem rather than only special-casing the mount itself.
|
|
214
|
+
const asDir = webDirRedirect(p);
|
|
215
|
+
if (asDir) {
|
|
216
|
+
res.writeHead(302, { location: atPath(asDir), ...securityHeaders(allowed, false) });
|
|
217
|
+
res.end();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
return serveWeb(res, p, mount, allowed);
|
|
221
|
+
}
|
|
222
|
+
// The bare URL means "show me what this agent wants from me now".
|
|
223
|
+
// Keyed on the credential FILE, never on configured(): a healthy
|
|
224
|
+
// install whose pod is briefly unreachable reports itself
|
|
225
|
+
// unconfigured for up to an hour, and must not be sent to setup.
|
|
226
|
+
if (p === '/' || p === '/index.html') {
|
|
227
|
+
if (!(agent.home && hasCredential(agent.home))) {
|
|
228
|
+
res.writeHead(302, { location: atPath(SETUP_PAGE), ...securityHeaders(allowed, false) });
|
|
229
|
+
res.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Opening the bare origin gets this actor's own client — the framed
|
|
233
|
+
// view with the bar — not the unbound app. The client page frames
|
|
234
|
+
// `/` itself and that load says so (Sec-Fetch-Dest: iframe), so only
|
|
235
|
+
// a top-level navigation is sent onward. The one top-level landing
|
|
236
|
+
// that must NOT be sent onward is the OAuth return, `/?code=…`: the
|
|
237
|
+
// client registered `/` as its redirect URI and only the app at `/`
|
|
238
|
+
// can exchange the code — the framed page would drop it and leave
|
|
239
|
+
// the client logged out.
|
|
240
|
+
const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
241
|
+
const oauthLanding = /[?&](code|error)=/.test(q);
|
|
242
|
+
if (!oauthLanding && req.headers['sec-fetch-dest'] === 'document') {
|
|
243
|
+
res.writeHead(302, { location: atPath('/admin/client/') + q, ...securityHeaders(allowed, false) });
|
|
244
|
+
res.end();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return serveStatic(res, p, allowed);
|
|
249
|
+
}
|
|
250
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'POST only' });
|
|
251
|
+
// A body we refuse to read is the caller's mistake, not ours — 400 says
|
|
252
|
+
// so, where the catch-all below would have called it a server fault.
|
|
253
|
+
let body;
|
|
254
|
+
try { body = await readBody(req); }
|
|
255
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
256
|
+
if (!OPEN_POSTS.has(p) && !agent.configured()) {
|
|
257
|
+
return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
|
|
258
|
+
}
|
|
259
|
+
if (!embedded && LOCAL_ONLY_POSTS.has(p) && !allowed.isLocalRequest(req)) {
|
|
260
|
+
return json(res, 403, { error: 'setup and configuration are available on this machine only' });
|
|
261
|
+
}
|
|
262
|
+
for (const r of ROUTES) if (await r.post?.(p, body, ctx, req, res)) return;
|
|
263
|
+
return json(res, 404, { error: 'unknown endpoint' });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
// The caller gets the real message: this server binds loopback only, sets
|
|
266
|
+
// no CORS headers, and its one reader is the operator. Hiding the reason
|
|
267
|
+
// from them buys nothing and costs a trip to the log. Stack stays here.
|
|
268
|
+
log(`admin ${p}: ${e.stack || e.message}`);
|
|
269
|
+
return json(res, 500, { error: e.message || String(e) });
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return { handler, masto, c2s, streaming };
|
|
274
|
+
}
|