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,393 @@
|
|
|
1
|
+
// setup.mjs — the first run and the process: setup driven from the page,
|
|
2
|
+
// a new actor in a new home, starting a stopped one, updating and
|
|
3
|
+
// restarting, moving the private half, and stopping this process.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { isCrossSiteNavigation } from '../../../shared/guard.mjs';
|
|
11
|
+
import { identityHomes, rootOf, tildify, writeJsonAtomic } from '../../home.mjs';
|
|
12
|
+
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
|
|
13
|
+
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
14
|
+
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
|
|
15
|
+
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
16
|
+
import { yieldDirectory } from '../../../gateway/directory.mjs';
|
|
17
|
+
import { localFetch } from '../../../client/localapi.mjs';
|
|
18
|
+
import { projectRoot, SETUP_PAGE } from '../static.mjs';
|
|
19
|
+
import { secureOrigin } from '../origins.mjs';
|
|
20
|
+
|
|
21
|
+
// The signup carry-over: gateway and identity parameters a signup page
|
|
22
|
+
// recorded for this install (written by the installer, consumed by the first
|
|
23
|
+
// setup run). Read fresh each time — the file is deleted once consumed.
|
|
24
|
+
function readFirstRun() {
|
|
25
|
+
const file = process.env.AP_FIRST_RUN;
|
|
26
|
+
if (!file) return null;
|
|
27
|
+
try {
|
|
28
|
+
const fr = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
return fr && typeof fr === 'object' ? fr : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The same config shape the `gateway` CLI command writes.
|
|
34
|
+
function gatewayConfigFrom(fr) {
|
|
35
|
+
if (!fr?.gateway || !fr?.secret) return null;
|
|
36
|
+
const target = String(fr.gateway);
|
|
37
|
+
return fr.fronted
|
|
38
|
+
? { url: target.replace(/ap\/actor$/, 'ap/inbox/'), frontActor: target,
|
|
39
|
+
mode: 'shadow', hmacSecret: String(fr.secret) }
|
|
40
|
+
: { url: target.replace(/\/?$/, '/'), mode: 'shadow', hmacSecret: String(fr.secret) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
|
|
44
|
+
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
45
|
+
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
46
|
+
|
|
47
|
+
// ---- setup and configuration, for the pages under /admin/ ----
|
|
48
|
+
// These answer for a group too: a group is set up in the browser like
|
|
49
|
+
// anything else, and it has a display name to change.
|
|
50
|
+
if (req.method === 'GET' && p === '/setup/state') {
|
|
51
|
+
const home = agent.home || null;
|
|
52
|
+
const held = home && hasCredential(home) ? agent.readCredential?.() : null;
|
|
53
|
+
return json(res, 200, {
|
|
54
|
+
hasCredential: !!(home && hasCredential(home)),
|
|
55
|
+
configured: agent.configured(),
|
|
56
|
+
// Not the same question: a crashed setup leaves a credential with no
|
|
57
|
+
// actor behind it, and that is finishable without minting a second.
|
|
58
|
+
resumable: !!(home && hasCredential(home)) && !agent.configured(),
|
|
59
|
+
running: ctx.setup.run?.phase === 'running',
|
|
60
|
+
phase: ctx.setup.run?.phase || 'idle',
|
|
61
|
+
home,
|
|
62
|
+
port,
|
|
63
|
+
handle: agent.store.getConfig()?.handle || allowed.label || null,
|
|
64
|
+
kind: agent.store.getConfig()?.kind || null,
|
|
65
|
+
origins: {
|
|
66
|
+
loopback: publicOrigin
|
|
67
|
+
|| `https://localhost:${port}/`,
|
|
68
|
+
named: publicOrigin
|
|
69
|
+
? null
|
|
70
|
+
: allowed.label
|
|
71
|
+
? (true
|
|
72
|
+
? `https://${allowed.label}.localhost:${port}/`
|
|
73
|
+
: `http://${allowed.label}.localhost:${port}/`)
|
|
74
|
+
: null,
|
|
75
|
+
},
|
|
76
|
+
// Set in the environment, the password never needs to reach the page.
|
|
77
|
+
passwordSupplied: !!process.env.AP_PASSWORD,
|
|
78
|
+
identity: held ? { pod: held.remotePod, issuer: held.issuerOrigin, root: held.root || null } : null,
|
|
79
|
+
defaults: { issuer: 'https://solidcommunity.net', port: 8030 },
|
|
80
|
+
// What a signup page arranged for this install, so the form opens
|
|
81
|
+
// pre-filled. The receipt secret stays out of it — the /setup run
|
|
82
|
+
// reads that from the file itself.
|
|
83
|
+
firstRun: (() => {
|
|
84
|
+
if (agent.configured()) return null;
|
|
85
|
+
const fr = readFirstRun();
|
|
86
|
+
if (!fr?.handle) return null;
|
|
87
|
+
let gatewayHost = null;
|
|
88
|
+
try { gatewayHost = new URL(fr.gateway).host; } catch { /* no gateway named */ }
|
|
89
|
+
return { pod: fr.pod || null, issuer: fr.issuer || null, handle: String(fr.handle),
|
|
90
|
+
kind: fr.kind === 'group' ? 'group' : 'person', fronted: !!fr.fronted, gatewayHost };
|
|
91
|
+
})(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'GET' && p === '/setup/progress') {
|
|
95
|
+
return json(res, 200, ctx.setup.run || { phase: 'idle', steps: [], error: null, result: null });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
|
|
102
|
+
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
103
|
+
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
104
|
+
switch (p) {
|
|
105
|
+
// Stop an agent whose pidfile is gone and which no terminal owns
|
|
106
|
+
// (backgrounded, orphaned by a closed shell). It used to sit ABOVE both
|
|
107
|
+
// the configured() gate and the isLocal one, so it was the only
|
|
108
|
+
// state-changing route that needed neither — reachable from any host
|
|
109
|
+
// AP_ALLOWED_HOSTS named, on an agent that had never been set up.
|
|
110
|
+
case '/shutdown': {
|
|
111
|
+
json(res, 200, { ok: true, stopping: process.pid });
|
|
112
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// ---- setup, driven by the page at /admin/setup/ ----
|
|
116
|
+
case '/setup/check': return json(res, 200, preflight(body));
|
|
117
|
+
// Discard a credential that never finished setup, so the account and
|
|
118
|
+
// pod can be entered again. The credential a CSS server mints is shown
|
|
119
|
+
// once, so a setup that stops after the mint (a wrong pod answers 401
|
|
120
|
+
// to the first write) leaves the form in "finish" mode with no way to
|
|
121
|
+
// re-enter what was wrong. This removes it locally and reopens the full
|
|
122
|
+
// form. It does NOT revoke server-side — that needs the account
|
|
123
|
+
// password (`fedipod revoke-credential`); the old credential is left on
|
|
124
|
+
// the account, revocable from its dashboard.
|
|
125
|
+
case '/setup/reset': {
|
|
126
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
127
|
+
// A working identity is never swapped out this way — that is a
|
|
128
|
+
// teardown (`fedipod retire`), not a half-finished setup.
|
|
129
|
+
if (agent.configured()) {
|
|
130
|
+
return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
|
|
131
|
+
}
|
|
132
|
+
if (ctx.setup.run?.phase === 'running') {
|
|
133
|
+
return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
|
|
134
|
+
}
|
|
135
|
+
const home = agent.home;
|
|
136
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
|
|
137
|
+
const removed = hasCredential(home);
|
|
138
|
+
if (removed) fs.rmSync(credentialPath(home), { force: true });
|
|
139
|
+
// Drop the pod handle too, so configured() cannot flicker true off a
|
|
140
|
+
// stale in-memory session while the fresh form is filled in.
|
|
141
|
+
agent.remote = null;
|
|
142
|
+
ctx.setup.run = null;
|
|
143
|
+
return json(res, 200, { ok: true, removed });
|
|
144
|
+
}
|
|
145
|
+
case '/setup': {
|
|
146
|
+
// A visited page must not be able to navigate this into existence.
|
|
147
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
148
|
+
if (agent.configured()) {
|
|
149
|
+
return json(res, 409, {
|
|
150
|
+
error: 'this home already holds an identity',
|
|
151
|
+
pod: agent.store.getConfig()?.remotePod || null,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (ctx.setup.run?.phase === 'running') {
|
|
155
|
+
return json(res, 409, { error: 'setup is already running', phase: 'running' });
|
|
156
|
+
}
|
|
157
|
+
const home = agent.home;
|
|
158
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to set up' });
|
|
159
|
+
// A crashed setup left a credential with no actor behind it. That is
|
|
160
|
+
// finishable, and must NOT mint a second credential — the first is
|
|
161
|
+
// unrecoverable and would be orphaned.
|
|
162
|
+
const resuming = hasCredential(home);
|
|
163
|
+
const answers = { ...body, password: body.password || process.env.AP_PASSWORD || '' };
|
|
164
|
+
// The signup carry-over: attach to the gateway the signup arranged,
|
|
165
|
+
// set before the first publish so the actor never advertises the pod
|
|
166
|
+
// inbox first. The secret comes from the installer's file, never the
|
|
167
|
+
// page.
|
|
168
|
+
const gatewayCfg = gatewayConfigFrom(readFirstRun());
|
|
169
|
+
if (gatewayCfg) answers.gateway = gatewayCfg;
|
|
170
|
+
const bad = setupInputError(answers, resuming);
|
|
171
|
+
if (bad) return json(res, 400, { error: bad });
|
|
172
|
+
if (!resuming) {
|
|
173
|
+
const pre = preflight(answers);
|
|
174
|
+
if (!pre.ok) return json(res, 400, { ...pre, error: pre.error || pre.refusal });
|
|
175
|
+
}
|
|
176
|
+
// Widen the allowlist now, not at the end: the page offers to move to
|
|
177
|
+
// the named origin, which has to answer before it is offered.
|
|
178
|
+
allowed.setHandle(answers.handle);
|
|
179
|
+
ctx.setup.run = newRun();
|
|
180
|
+
// Deliberately not awaited. The credential a CSS server mints is
|
|
181
|
+
// shown once, so what a run depends on is the file it writes, never
|
|
182
|
+
// a connection a closed tab can take with it.
|
|
183
|
+
runSetup({ home, agent, answers, run: ctx.setup.run, log })
|
|
184
|
+
.then((r) => {
|
|
185
|
+
// The carried secret is consumed; a finished setup deletes it.
|
|
186
|
+
if (r?.phase === 'done' && process.env.AP_FIRST_RUN) {
|
|
187
|
+
try { fs.unlinkSync(process.env.AP_FIRST_RUN); } catch { /* already gone */ }
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.catch(e => { ctx.setup.run.phase = 'error'; ctx.setup.run.error = e.message; });
|
|
191
|
+
return json(res, 202, { ok: true, running: true, resuming });
|
|
192
|
+
}
|
|
193
|
+
// One identity per home, so a new actor means a new home, a free port
|
|
194
|
+
// and a process there — then its own setup, run with the answers this
|
|
195
|
+
// page collected. The reply is the address of the new actor's page,
|
|
196
|
+
// which is where the progress of that setup is reported.
|
|
197
|
+
case '/new-actor': {
|
|
198
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
199
|
+
const handle = String(body.handle || '').trim().toLowerCase();
|
|
200
|
+
// The name first, and before anything is created: it is what becomes
|
|
201
|
+
// a directory, and "mode must be new or existing" is a poor answer to
|
|
202
|
+
// a handle that could climb out of profiles/.
|
|
203
|
+
// Named after the handle rather than generated, so `profiles` and
|
|
204
|
+
// `--profile <name>` stay legible a year from now.
|
|
205
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,30}$/.test(handle)) {
|
|
206
|
+
return json(res, 400, { error: 'a handle is letters, digits, hyphens and underscores' });
|
|
207
|
+
}
|
|
208
|
+
const dir = path.join(rootOf(path.resolve(agent.home || '')), 'profiles', handle);
|
|
209
|
+
if (fs.existsSync(path.join(dir, 'credential.json'))) {
|
|
210
|
+
return json(res, 409, { error: `${handle} already exists — it is in the list above` });
|
|
211
|
+
}
|
|
212
|
+
// Then everything else setup needs, so a missing password is refused
|
|
213
|
+
// here rather than after a process has been started for it.
|
|
214
|
+
const bad = setupInputError({ ...body, handle });
|
|
215
|
+
if (bad) return json(res, 400, { error: bad });
|
|
216
|
+
const newPort = await freePortFrom(port + 1);
|
|
217
|
+
if (!newPort) return json(res, 503, { error: 'no free port in the next 50' });
|
|
218
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
219
|
+
const child = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
|
|
220
|
+
detached: true, stdio: 'ignore',
|
|
221
|
+
env: { ...process.env, AP_HOME: dir, AP_PORT: String(newPort), AP_PROFILE: '' },
|
|
222
|
+
});
|
|
223
|
+
child.unref();
|
|
224
|
+
// Answer only once it is listening: handing back a URL that is not
|
|
225
|
+
// up yet shows a connection error instead of the setup form.
|
|
226
|
+
let listening = false;
|
|
227
|
+
for (let i = 0; i < 60 && !listening; i++) {
|
|
228
|
+
await new Promise(r => setTimeout(r, 250));
|
|
229
|
+
listening = await localFetch(agent.home, newPort, '/status', { timeout: 1000 })
|
|
230
|
+
.then(r => r.ok).catch(() => false);
|
|
231
|
+
}
|
|
232
|
+
if (!listening) return json(res, 504, { error: `started it on ${newPort} but it never answered` });
|
|
233
|
+
|
|
234
|
+
// Its own /setup owns the work — minting the credential, provisioning
|
|
235
|
+
// the pod, publishing. It answers 202 and runs in the background, so
|
|
236
|
+
// the page that opens next is the one reporting progress.
|
|
237
|
+
const started = await localFetch(agent.home, newPort, '/setup', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'content-type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({ ...body, handle }),
|
|
241
|
+
}).then(async r => ({ status: r.status, json: await r.json().catch(() => null) }))
|
|
242
|
+
.catch(e => ({ status: 0, json: { error: e.message } }));
|
|
243
|
+
if (started.status !== 202) {
|
|
244
|
+
return json(res, started.status || 502, {
|
|
245
|
+
error: started.json?.error || `the new agent refused setup (HTTP ${started.status})`,
|
|
246
|
+
port: newPort, url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return json(res, 200,
|
|
250
|
+
{ ok: true, handle, port: newPort,
|
|
251
|
+
url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}` });
|
|
252
|
+
}
|
|
253
|
+
// Start an actor that is not running, so its page can be visited. The
|
|
254
|
+
// same spawn as /new-actor, minus the making: this one has a home
|
|
255
|
+
// already. Its recorded port is preferred so its address stays what it
|
|
256
|
+
// was, but a port something else has taken is walked past, not fought.
|
|
257
|
+
case '/start-actor': {
|
|
258
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
259
|
+
const name = String(body.name || '');
|
|
260
|
+
const found = identityHomes(rootOf(path.resolve(agent.home || ''))).find(h => h.name === name);
|
|
261
|
+
if (!found) return json(res, 404, { error: `no identity called ${name}` });
|
|
262
|
+
|
|
263
|
+
let want = null;
|
|
264
|
+
try { want = JSON.parse(fs.readFileSync(path.join(found.dir, 'agent.json'), 'utf8')).port || null; } catch {}
|
|
265
|
+
if (want) {
|
|
266
|
+
// Its own handle, not the profile directory's name: usually the
|
|
267
|
+
// same, but a renamed profile makes them differ and the origin has
|
|
268
|
+
// to be one that agent's own guard will accept.
|
|
269
|
+
const live = await localFetch(agent.home, want, '/status', { timeout: 1000 })
|
|
270
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
271
|
+
if (live) {
|
|
272
|
+
return json(res, 200, {
|
|
273
|
+
ok: true, name, port: want, already: true,
|
|
274
|
+
url: `${secureOrigin(live.handle || name, want)}/`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// The port is not the only evidence it is running. An agent from
|
|
279
|
+
// before agent.json was written at startup has a live pidfile and no
|
|
280
|
+
// recorded port, and spawning past that gives one home two agents —
|
|
281
|
+
// which is exactly how this machine ended up with two on profiles/jeff.
|
|
282
|
+
let held = null;
|
|
283
|
+
try { held = Number(fs.readFileSync(path.join(found.dir, 'agent.pid'), 'utf8').trim()) || null; } catch {}
|
|
284
|
+
if (held) {
|
|
285
|
+
let alive = false;
|
|
286
|
+
try { process.kill(held, 0); alive = true; } catch { /* gone */ }
|
|
287
|
+
if (alive) {
|
|
288
|
+
return json(res, 409, {
|
|
289
|
+
error: `${name} is already running as pid ${held}, on a port it never recorded. `
|
|
290
|
+
+ `Stop it first: kill ${held}`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// A recorded port held by the directory door is still this
|
|
295
|
+
// identity's port — the door steps aside for its owner.
|
|
296
|
+
if (want && !await portFree(want)) await yieldDirectory(want, { portFree });
|
|
297
|
+
const on = (want && await portFree(want)) ? want : await freePortFrom(port + 1);
|
|
298
|
+
if (!on) return json(res, 503, { error: 'no free port in the next 50' });
|
|
299
|
+
|
|
300
|
+
const started = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
|
|
301
|
+
detached: true, stdio: 'ignore',
|
|
302
|
+
env: { ...process.env, AP_HOME: found.dir, AP_PORT: String(on), AP_PROFILE: '' },
|
|
303
|
+
});
|
|
304
|
+
started.unref();
|
|
305
|
+
for (let i = 0; i < 60; i++) {
|
|
306
|
+
await new Promise(r => setTimeout(r, 250));
|
|
307
|
+
const up = await localFetch(agent.home, on, '/status', { timeout: 1000 })
|
|
308
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
309
|
+
if (up) {
|
|
310
|
+
return json(res, 200, { ok: true, name, port: on,
|
|
311
|
+
url: `${secureOrigin(up.handle || name, on)}/` });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return json(res, 504, { error: `started ${name} on ${on} but it never answered` });
|
|
315
|
+
}
|
|
316
|
+
// Pull the latest published FediPod and restart the agents. What the
|
|
317
|
+
// installer does on a re-run, offered where the version line is.
|
|
318
|
+
case '/update': {
|
|
319
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
320
|
+
const { runUpdate, restartAgents, repoRoot } = await import('../../update.mjs');
|
|
321
|
+
const r = runUpdate({ log });
|
|
322
|
+
if (!r.ok) return json(res, 409, { error: r.note });
|
|
323
|
+
json(res, 200, { ok: true, note: r.note, restarting: true });
|
|
324
|
+
// The response first, the restart a beat later — a self-restart must
|
|
325
|
+
// not take the answer down with it.
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
if (restartAgents({ log }) === 'self') {
|
|
328
|
+
const child = spawn(process.execPath, [path.join(repoRoot(), 'run-agent.mjs')],
|
|
329
|
+
{ detached: true, stdio: 'ignore', env: process.env });
|
|
330
|
+
child.unref();
|
|
331
|
+
setTimeout(() => process.exit(0), 300);
|
|
332
|
+
}
|
|
333
|
+
}, 200);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
// Move the private half — every state document and the RDF tree — while
|
|
337
|
+
// the agent runs: quiesce this process's writers, flush, copy, verify,
|
|
338
|
+
// repoint the credential, then reconnect on the new location. The old
|
|
339
|
+
// copy is left where it was, exactly as the CLI move leaves it.
|
|
340
|
+
case '/state-move': {
|
|
341
|
+
const cred = agent.readCredential?.();
|
|
342
|
+
if (!cred) return json(res, 409, { error: 'no credential — run setup first' });
|
|
343
|
+
const to = String(body.to || '').trim();
|
|
344
|
+
if (!to) return json(res, 400, { error: 'say where it should go' });
|
|
345
|
+
let target = null;
|
|
346
|
+
if (to !== 'pod') {
|
|
347
|
+
// A path or a URL — two chars before the colon, so a Windows drive
|
|
348
|
+
// letter reads as a path rather than a scheme.
|
|
349
|
+
const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
|
|
350
|
+
const raw = asPath
|
|
351
|
+
? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
|
|
352
|
+
: to;
|
|
353
|
+
target = raw.endsWith('/') ? raw : raw + '/';
|
|
354
|
+
try { new URL(target); } catch { return json(res, 400, { error: `"${to}" is not a container URL or a path` }); }
|
|
355
|
+
if (/^https?:/i.test(target)) {
|
|
356
|
+
const bad = insecureUrlReason(target, 'private-data address');
|
|
357
|
+
if (bad) return json(res, 400, { error: bad });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const whereState = (c) => (c.privateRoot ? tildify(c.privateRoot) : 'on the pod');
|
|
361
|
+
if ((cred.privateRoot || null) === target) {
|
|
362
|
+
return json(res, 200, { ok: true, docs: 0, notes: 0, unchanged: true, now: whereState(cred) });
|
|
363
|
+
}
|
|
364
|
+
await agent.requestTakeover?.();
|
|
365
|
+
agent.intake?.stop(); agent.deliverer?.stop(); agent.tagfeed?.stop(); agent.importer?.stop();
|
|
366
|
+
try {
|
|
367
|
+
await agent.store.flush();
|
|
368
|
+
const destCred = { ...cred, privateRoot: target };
|
|
369
|
+
const copied = await copyPrivateHalf({
|
|
370
|
+
from: { state: agent.privateStorage(cred, 'state') },
|
|
371
|
+
to: { state: agent.privateStorage(destCred, 'state') },
|
|
372
|
+
log,
|
|
373
|
+
});
|
|
374
|
+
if (target) cred.privateRoot = target; else delete cred.privateRoot;
|
|
375
|
+
if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
|
|
376
|
+
writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
|
|
377
|
+
// connect() rebuilds everything that pointed at the old location —
|
|
378
|
+
// store, publisher, intake — and restarts them.
|
|
379
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
380
|
+
agent.stateLoaded = false;
|
|
381
|
+
await agent.connect();
|
|
382
|
+
return json(res, 200, { ok: true, ...copied, now: whereState(cred) });
|
|
383
|
+
} catch (e) {
|
|
384
|
+
// The credential is only repointed after a verified copy, so on any
|
|
385
|
+
// failure reconnect puts the agent back to work where it was.
|
|
386
|
+
agent.stateLoaded = false;
|
|
387
|
+
await agent.connect().catch(() => {});
|
|
388
|
+
return json(res, 502, { error: e.message });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
default: return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// social.mjs — the owner acting on the social graph, and a group operator
|
|
2
|
+
// moderating: posting, the tag feed, follows, mutes, blocks, ejecting,
|
|
3
|
+
// retracting, review, joins, the moderation queue.
|
|
4
|
+
|
|
5
|
+
import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement, admitRequest, refuseRequest, applyModeration } from '../../../core/social.mjs';
|
|
6
|
+
|
|
7
|
+
export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
|
|
8
|
+
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
9
|
+
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
10
|
+
|
|
11
|
+
if (req.method === 'GET' && p === '/tagfeed') {
|
|
12
|
+
return json(res, 200, agent.tagfeed
|
|
13
|
+
? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
|
|
14
|
+
: { error: 'agent not configured' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
|
|
21
|
+
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
22
|
+
const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
|
|
23
|
+
switch (p) {
|
|
24
|
+
case '/post': {
|
|
25
|
+
if (!body.content) return json(res, 400, { error: 'content required' });
|
|
26
|
+
const note = await agent.publisher.publishNote(body.content, { inReplyTo: body.inReplyTo });
|
|
27
|
+
return json(res, 200, { ok: true, id: note.id });
|
|
28
|
+
}
|
|
29
|
+
case '/tagfeed': return json(res, 200, agent.tagfeed.setConfig(body));
|
|
30
|
+
case '/archive': {
|
|
31
|
+
// Whether drained mail's original bytes are kept in the private
|
|
32
|
+
// half's inbox-archive/. Absent means on.
|
|
33
|
+
const cfg = agent.store.getConfig();
|
|
34
|
+
agent.store.setConfig({ ...cfg, archiveInbox: !!body.on });
|
|
35
|
+
await agent.store.flush();
|
|
36
|
+
return json(res, 200, { ok: true, archiveInbox: !!body.on });
|
|
37
|
+
}
|
|
38
|
+
case '/follow': {
|
|
39
|
+
// By handle normally; by actor URL when there is no handle to resolve —
|
|
40
|
+
// a pod on a path, or anything WebFinger cannot answer for. Pasting an
|
|
41
|
+
// actor URL is a normal way to follow in Mastodon too.
|
|
42
|
+
if (body.actor) { await followActor(agent, body.actor); return json(res, 200, { ok: true, actor: body.actor }); }
|
|
43
|
+
if (!body.handle) return json(res, 400, { error: 'handle or actor required' });
|
|
44
|
+
return json(res, 200, await followHandle(agent, body.handle));
|
|
45
|
+
}
|
|
46
|
+
case '/unfollow': return json(res, 200, await unfollowActor(agent, body.actor));
|
|
47
|
+
case '/mute':
|
|
48
|
+
case '/unmute': {
|
|
49
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
50
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
51
|
+
const m = agent.store.getMuted();
|
|
52
|
+
m.actors = p === '/mute'
|
|
53
|
+
? [...new Set([...m.actors, body.actor])]
|
|
54
|
+
: m.actors.filter(a => a !== body.actor);
|
|
55
|
+
agent.store.setMuted(m);
|
|
56
|
+
await agent.store.flush();
|
|
57
|
+
return json(res, 200, { ok: true, actors: m.actors });
|
|
58
|
+
}
|
|
59
|
+
case '/eject': {
|
|
60
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
61
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
62
|
+
const r = await ejectFollower(agent, body.actor);
|
|
63
|
+
await agent.store.flush();
|
|
64
|
+
return json(res, 200, r);
|
|
65
|
+
}
|
|
66
|
+
case '/retract': {
|
|
67
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
68
|
+
if (!body.noteId) return json(res, 400, { error: 'noteId required' });
|
|
69
|
+
const r = await retractAnnouncement(agent, body.noteId);
|
|
70
|
+
await agent.store.flush();
|
|
71
|
+
return json(res, 200, r);
|
|
72
|
+
}
|
|
73
|
+
case '/review': {
|
|
74
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
75
|
+
agent.store.setConfig({ ...agent.store.getConfig(), review: !!body.on });
|
|
76
|
+
await agent.store.flush();
|
|
77
|
+
return json(res, 200, { ok: true, review: !!body.on });
|
|
78
|
+
}
|
|
79
|
+
case '/describe': {
|
|
80
|
+
const cfg = { ...agent.store.getConfig() };
|
|
81
|
+
if ('summary' in body) cfg.summary = body.summary || undefined;
|
|
82
|
+
if ('icon' in body) cfg.icon = body.icon || undefined;
|
|
83
|
+
agent.store.setConfig(cfg);
|
|
84
|
+
Object.assign(agent.publisher.config, { summary: cfg.summary, icon: cfg.icon });
|
|
85
|
+
await agent.store.flush();
|
|
86
|
+
await agent.publisher.publishProfile(); // the bio and avatar are on the wire
|
|
87
|
+
return json(res, 200, { ok: true, summary: cfg.summary || null, icon: cfg.icon || null });
|
|
88
|
+
}
|
|
89
|
+
case '/joins': {
|
|
90
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
91
|
+
const cfg = { ...agent.store.getConfig(), approveJoins: !!body.approve };
|
|
92
|
+
agent.store.setConfig(cfg);
|
|
93
|
+
agent.publisher.config.approveJoins = !!body.approve;
|
|
94
|
+
await agent.store.flush();
|
|
95
|
+
// Unlike review, this one is visible to the fediverse: the actor
|
|
96
|
+
// document carries manuallyApprovesFollowers, so nothing changes for
|
|
97
|
+
// anyone until it is republished.
|
|
98
|
+
await agent.publisher.publishProfile();
|
|
99
|
+
return json(res, 200, { ok: true, approveJoins: !!body.approve });
|
|
100
|
+
}
|
|
101
|
+
case '/admit':
|
|
102
|
+
case '/refuse': {
|
|
103
|
+
// Kind-agnostic, like the queue they act on: admitting adds a
|
|
104
|
+
// follower and Accepts, refusing Rejects. A person needs both.
|
|
105
|
+
// `all` answers the whole queue — the shape a migration wave arrives
|
|
106
|
+
// in — with one collections republish for the lot.
|
|
107
|
+
if (p === '/admit' && body.all === true) {
|
|
108
|
+
const waiting = agent.store.getRequests().map(r => r.actor);
|
|
109
|
+
let admitted = 0;
|
|
110
|
+
for (const actor of waiting) {
|
|
111
|
+
try { await admitRequest(agent, actor, { publish: false }); admitted++; }
|
|
112
|
+
catch (e) { log(`admit ${actor} failed: ${e.message}`); }
|
|
113
|
+
}
|
|
114
|
+
if (admitted) await agent.publisher.publishCollections({ followers: true, pending: true });
|
|
115
|
+
await agent.store.flush();
|
|
116
|
+
return json(res, 200, { ok: true, admitted, requests: agent.store.getRequests().length });
|
|
117
|
+
}
|
|
118
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
119
|
+
const r = p === '/admit' ? await admitRequest(agent, body.actor)
|
|
120
|
+
: await refuseRequest(agent, body.actor);
|
|
121
|
+
await agent.store.flush();
|
|
122
|
+
return json(res, 200, { ...r, requests: agent.store.getRequests().length });
|
|
123
|
+
}
|
|
124
|
+
case '/approve':
|
|
125
|
+
case '/decline': {
|
|
126
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
127
|
+
if (!body.noteId) return json(res, 400, { error: 'noteId required' });
|
|
128
|
+
const held = agent.store.getPending().some(x => x.noteId === body.noteId);
|
|
129
|
+
if (!held) return json(res, 404, { error: 'not held for review' });
|
|
130
|
+
if (p === '/approve') await agent.intake.amplify(body.noteId, { approved: true });
|
|
131
|
+
else agent.store.setPending(agent.store.getPending().filter(x => x.noteId !== body.noteId));
|
|
132
|
+
await agent.store.flush();
|
|
133
|
+
return json(res, 200, { ok: true, noteId: body.noteId, pending: agent.store.getPending().length });
|
|
134
|
+
}
|
|
135
|
+
// Answer one queued moderation ask: apply it (the operator vouching
|
|
136
|
+
// for a delivery nothing else can vouch for) or dismiss it.
|
|
137
|
+
case '/modqueue': {
|
|
138
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
139
|
+
const q = agent.store.read('modqueue.json', []);
|
|
140
|
+
const entry = q.find(e => e.id === body.id);
|
|
141
|
+
if (!entry) return json(res, 404, { error: 'no such queued action' });
|
|
142
|
+
if (body.action !== 'apply' && body.action !== 'dismiss') {
|
|
143
|
+
return json(res, 400, { error: 'action must be apply or dismiss' });
|
|
144
|
+
}
|
|
145
|
+
let applied = null;
|
|
146
|
+
if (body.action === 'apply') applied = await applyModeration(agent, entry);
|
|
147
|
+
agent.store.write('modqueue.json', q.filter(e => e.id !== entry.id));
|
|
148
|
+
await agent.store.flush();
|
|
149
|
+
return json(res, 200, {
|
|
150
|
+
ok: true, action: body.action, type: entry.type, moderator: entry.moderator,
|
|
151
|
+
...(applied && typeof applied === 'object' ? { result: applied } : {}),
|
|
152
|
+
remaining: agent.store.read('modqueue.json', []).length,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Symmetrical with /block, and open for the same reason: a block made
|
|
156
|
+
// by mistake is worth undoing before federation is even configured.
|
|
157
|
+
case '/unblock': {
|
|
158
|
+
const b = agent.store.getBlocklist();
|
|
159
|
+
const before = b.domains.length + b.actors.length;
|
|
160
|
+
if (body.actor) b.actors = b.actors.filter(a => a !== body.actor);
|
|
161
|
+
else if (body.domain) b.domains = b.domains.filter(d => d !== body.domain);
|
|
162
|
+
else return json(res, 400, { error: 'domain or actor required' });
|
|
163
|
+
agent.store.setBlocklist(b);
|
|
164
|
+
// The published blocked collection (FEP-c648) follows, best-effort.
|
|
165
|
+
Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
166
|
+
return json(res, 200, {
|
|
167
|
+
ok: true, removed: before - (b.domains.length + b.actors.length),
|
|
168
|
+
domains: b.domains, actors: b.actors,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case '/block': {
|
|
172
|
+
const b = agent.store.getBlocklist();
|
|
173
|
+
if (body.actor) {
|
|
174
|
+
if (!/^https?:\/\/\S+$/.test(body.actor)) return json(res, 400, { error: 'actor must be a URL' });
|
|
175
|
+
if (!b.actors.includes(body.actor)) b.actors.push(body.actor);
|
|
176
|
+
} else if (body.domain) {
|
|
177
|
+
if (!b.domains.includes(body.domain)) b.domains.push(body.domain);
|
|
178
|
+
} else {
|
|
179
|
+
return json(res, 400, { error: 'domain or actor required' });
|
|
180
|
+
}
|
|
181
|
+
agent.store.setBlocklist(b);
|
|
182
|
+
// The published blocked collection (FEP-c648) follows, best-effort.
|
|
183
|
+
Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
184
|
+
return json(res, 200, { ok: true, domains: b.domains, actors: b.actors });
|
|
185
|
+
}
|
|
186
|
+
default: return false;
|
|
187
|
+
}
|
|
188
|
+
}
|