fedipod-server 0.7.0 → 0.8.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 +28 -8
- package/config/server.json +11 -0
- package/dist/claims.js +5 -4
- package/dist/components/context.jsonld +6 -0
- package/dist/directory.d.ts +11 -0
- package/dist/directory.js +19 -0
- package/dist/handler.d.ts +18 -2
- package/dist/handler.js +63 -27
- package/dist/handler.jsonld +27 -0
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +176 -6
- package/lib/bskygroup.mjs +1 -1
- package/lib/embed.mjs +6 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/front-core.mjs +74 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +101 -2
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +2 -2
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +31 -0
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +133 -0
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +20 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/acctfeed.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// acctfeed.mjs — polls each connected fediverse account's home timeline and
|
|
2
|
+
// notifications and merges them into the statuses index as kind 'acct'. View
|
|
3
|
+
// cache only: none of it is written to the pod, which holds followed and own
|
|
4
|
+
// content. Modeled on bskyfeed.mjs, which is modeled on tagfeed.mjs.
|
|
5
|
+
//
|
|
6
|
+
// A note is NOT re-fetched at its origin before being stored. The dereference
|
|
7
|
+
// rule guards material an unauthenticated stranger chose for us; this is the
|
|
8
|
+
// owner's own authenticated account answering with the result of the owner's
|
|
9
|
+
// own follow decisions there — the same trust anchor that lets bskyfeed skip
|
|
10
|
+
// it. A local block still applies, because that is the owner's decision.
|
|
11
|
+
//
|
|
12
|
+
// Config in fediacctfeed.json: { intervalMin, accounts: { <id>: marks } }.
|
|
13
|
+
|
|
14
|
+
import { sanitizeHtml } from './wire.mjs';
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = { intervalMin: 5 };
|
|
17
|
+
const PER_SWEEP = 40;
|
|
18
|
+
const MAX_PER_ACCT = 150;
|
|
19
|
+
const MAX_ACCT_ENTRIES = 400;
|
|
20
|
+
const BACKOFF_MIN_MS = 5 * 60_000;
|
|
21
|
+
const BACKOFF_MAX_MS = 2 * 60 * 60_000;
|
|
22
|
+
// Mastodon allows 300 requests per five minutes per token. Four go per sweep,
|
|
23
|
+
// so the floor is only ever reached by something else using the same token.
|
|
24
|
+
const RATE_FLOOR = 20;
|
|
25
|
+
|
|
26
|
+
const MEDIA_TYPES = {
|
|
27
|
+
image: 'image/jpeg', gifv: 'image/gif', video: 'video/mp4', audio: 'audio/mpeg',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class AcctFeed {
|
|
31
|
+
constructor({ store, accounts, log = console.log, onNotification = null }) {
|
|
32
|
+
Object.assign(this, { store, accounts, log, onNotification });
|
|
33
|
+
this.state = new Map(); // account id → { failures, quietUntil }
|
|
34
|
+
this.lastSweep = null;
|
|
35
|
+
this.lastAdded = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
config() { return { ...DEFAULTS, ...this.store.read('fediacctfeed.json', {}) }; }
|
|
39
|
+
|
|
40
|
+
setConfig(patch) {
|
|
41
|
+
const clean = {};
|
|
42
|
+
if (patch.intervalMin) clean.intervalMin = Math.max(1, Number(patch.intervalMin) || DEFAULTS.intervalMin);
|
|
43
|
+
this.store.write('fediacctfeed.json', { ...this.config(), ...clean });
|
|
44
|
+
this.stop();
|
|
45
|
+
this.start();
|
|
46
|
+
return this.config();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
marks(id) { return this.config().accounts?.[id] || {}; }
|
|
50
|
+
|
|
51
|
+
setMarks(id, patch) {
|
|
52
|
+
const cfg = this.config();
|
|
53
|
+
this.store.write('fediacctfeed.json', {
|
|
54
|
+
...cfg, accounts: { ...(cfg.accounts || {}), [id]: { ...(cfg.accounts?.[id] || {}), ...patch } },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start() {
|
|
59
|
+
this.stopped = false;
|
|
60
|
+
this.sweep().catch(e => this.log(`acctfeed: ${e.message}`));
|
|
61
|
+
const tick = () => {
|
|
62
|
+
this.timer = setTimeout(() => {
|
|
63
|
+
this.sweep()
|
|
64
|
+
.catch(e => this.log(`acctfeed: ${e.message}`))
|
|
65
|
+
.finally(() => { if (!this.stopped) tick(); });
|
|
66
|
+
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
67
|
+
this.timer.unref?.();
|
|
68
|
+
};
|
|
69
|
+
tick();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
73
|
+
|
|
74
|
+
// Per account, not per feed: one server being down must not silence the rest.
|
|
75
|
+
_stateOf(id) {
|
|
76
|
+
if (!this.state.has(id)) this.state.set(id, { failures: 0, quietUntil: 0 });
|
|
77
|
+
return this.state.get(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_backOff(id, handle, status, retryAfter) {
|
|
81
|
+
const st = this._stateOf(id);
|
|
82
|
+
st.failures += 1;
|
|
83
|
+
const ladder = Math.min(BACKOFF_MIN_MS * 2 ** (st.failures - 1), BACKOFF_MAX_MS);
|
|
84
|
+
const wait = retryAfter || Math.round(ladder * (0.85 + Math.random() * 0.3));
|
|
85
|
+
st.quietUntil = Date.now() + wait;
|
|
86
|
+
this.log(`acctfeed: ${handle} ${status ? `answered ${status}` : 'did not answer'} — not asking again for ${Math.round(wait / 60_000)} min`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One author into the shared actor cache. `uri` is the ActivityPub id, so a
|
|
90
|
+
// person the pod already knows from its own inbox stays one actor here.
|
|
91
|
+
_rememberAuthor(a) {
|
|
92
|
+
const url = a?.uri || a?.url;
|
|
93
|
+
if (!url) return null;
|
|
94
|
+
if (!this.store.getActors()[url]) {
|
|
95
|
+
this.store.cacheActor(url, {
|
|
96
|
+
name: a.display_name || a.username, preferredUsername: a.username,
|
|
97
|
+
icon: a.avatar || null, type: a.bot ? 'Service' : 'Person',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One Mastodon status into the statuses index. Never pre-checks for a
|
|
104
|
+
// duplicate: a repeat sighting is what carries the second account's
|
|
105
|
+
// provenance, and addStatus is the only thing that knows how to merge it.
|
|
106
|
+
_mirror(st, { acct, via = null, parents = null }) {
|
|
107
|
+
const noteId = st.uri;
|
|
108
|
+
if (!noteId) return false;
|
|
109
|
+
const actor = this._rememberAuthor(st.account);
|
|
110
|
+
if (!actor || this.store.isBlocked(actor)) return false;
|
|
111
|
+
const inReplyTo = st.in_reply_to_id ? parents?.get(String(st.in_reply_to_id)) : null;
|
|
112
|
+
const out = this.store.addStatus({
|
|
113
|
+
noteId, actor,
|
|
114
|
+
content: sanitizeHtml(st.content || ''),
|
|
115
|
+
published: st.created_at,
|
|
116
|
+
kind: 'acct',
|
|
117
|
+
sourceAccts: [{ acct, remoteId: String(st.id) }],
|
|
118
|
+
...(st.url && st.url !== noteId ? { link: st.url } : {}),
|
|
119
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
120
|
+
...(via ? { via } : {}),
|
|
121
|
+
...(st.spoiler_text ? { spoiler: st.spoiler_text } : {}),
|
|
122
|
+
...(st.media_attachments?.length ? {
|
|
123
|
+
attachments: st.media_attachments.map(m => ({
|
|
124
|
+
url: m.url, mediaType: MEDIA_TYPES[m.type] || 'image/jpeg', description: m.description || '',
|
|
125
|
+
})),
|
|
126
|
+
} : {}),
|
|
127
|
+
});
|
|
128
|
+
return !!out?.added;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Mastodon threads by its own local ids, which mean nothing anywhere else.
|
|
132
|
+
// A parent we already hold from this same account can be resolved locally,
|
|
133
|
+
// and one we do not hold simply has no parent here rather than a fetch.
|
|
134
|
+
_parentMap(acct) {
|
|
135
|
+
const map = new Map();
|
|
136
|
+
for (const s of this.store.getStatuses()) {
|
|
137
|
+
for (const v of s.sourceAccts || []) {
|
|
138
|
+
if (v.acct === acct && v.remoteId) map.set(String(v.remoteId), s.noteId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_rateGuard(id, handle, res) {
|
|
145
|
+
const left = Number(res.headers.get('x-ratelimit-remaining'));
|
|
146
|
+
if (!Number.isFinite(left) || left > RATE_FLOOR) return false;
|
|
147
|
+
const reset = Date.parse(res.headers.get('x-ratelimit-reset') || '');
|
|
148
|
+
const wait = Number.isFinite(reset) ? Math.max(0, reset - Date.now()) : BACKOFF_MIN_MS;
|
|
149
|
+
this._stateOf(id).quietUntil = Date.now() + wait;
|
|
150
|
+
this.log(`acctfeed: ${handle} has ${left} requests left — waiting ${Math.round(wait / 1000)}s`);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async sweep() {
|
|
155
|
+
const rows = (this.accounts?.list() || []).filter(r => r.token && r.enabled !== false);
|
|
156
|
+
if (!rows.length) return;
|
|
157
|
+
this.lastSweep = new Date().toISOString();
|
|
158
|
+
let added = 0;
|
|
159
|
+
// The 300ms debounce cannot coalesce a sweep that awaits between items —
|
|
160
|
+
// every fetch outlives it — so the whole sweep is one commit boundary.
|
|
161
|
+
// Without this, statuses.json is serialized whole once per post.
|
|
162
|
+
this.store.hold();
|
|
163
|
+
try {
|
|
164
|
+
for (const rec of rows) {
|
|
165
|
+
if (this.stopped) break;
|
|
166
|
+
added += await this._sweepOne(rec).catch((e) => {
|
|
167
|
+
this._backOff(rec.id, rec.handle, e.status || 0, null);
|
|
168
|
+
return 0;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
this._prune();
|
|
172
|
+
} finally {
|
|
173
|
+
this.store.release();
|
|
174
|
+
}
|
|
175
|
+
this.lastAdded = added;
|
|
176
|
+
if (added) this.log(`acctfeed: +${added} from ${rows.length} connected account(s)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _sweepOne(rec) {
|
|
180
|
+
const st = this._stateOf(rec.id);
|
|
181
|
+
if (st.quietUntil && Date.now() < st.quietUntil) return 0;
|
|
182
|
+
const marks = this.marks(rec.id);
|
|
183
|
+
const parents = this._parentMap(rec.id);
|
|
184
|
+
let added = 0;
|
|
185
|
+
|
|
186
|
+
const homeQ = marks.homeSinceId ? `&since_id=${encodeURIComponent(marks.homeSinceId)}` : '';
|
|
187
|
+
const homeRes = await this.accounts.api(rec.id, `/api/v1/timelines/home?limit=${PER_SWEEP}${homeQ}`);
|
|
188
|
+
if (this._rateGuard(rec.id, rec.handle, homeRes)) return 0;
|
|
189
|
+
const home = await homeRes.json().catch(() => []);
|
|
190
|
+
if (!Array.isArray(home)) throw new Error(`${rec.handle} answered with no timeline`);
|
|
191
|
+
for (const item of home) {
|
|
192
|
+
if (item?.reblog) {
|
|
193
|
+
// A boost: the inner post is the content, the booster is the carrier —
|
|
194
|
+
// the same envelope statusOrBoost already renders for our own timeline.
|
|
195
|
+
const via = this._rememberAuthor(item.account);
|
|
196
|
+
if (this._mirror(item.reblog, { acct: rec.id, via, parents })) added++;
|
|
197
|
+
} else if (item?.uri) {
|
|
198
|
+
if (this._mirror(item, { acct: rec.id, parents })) added++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Mastodon returns newest first, so the first row is the new mark.
|
|
202
|
+
// Ids are 19-digit snowflakes: opaque strings, never numbers.
|
|
203
|
+
if (home.length && home[0]?.id) this.setMarks(rec.id, { homeSinceId: String(home[0].id) });
|
|
204
|
+
|
|
205
|
+
const notifQ = marks.notifSinceId ? `&since_id=${encodeURIComponent(marks.notifSinceId)}` : '';
|
|
206
|
+
const notifRes = await this.accounts.api(rec.id, `/api/v1/notifications?limit=${PER_SWEEP}${notifQ}`);
|
|
207
|
+
if (this._rateGuard(rec.id, rec.handle, notifRes)) return added;
|
|
208
|
+
const notes = await notifRes.json().catch(() => []);
|
|
209
|
+
if (Array.isArray(notes)) {
|
|
210
|
+
for (const n of notes) {
|
|
211
|
+
if (!n?.account || String(n.account.id) === String(rec.accountId)) continue;
|
|
212
|
+
const actor = this._rememberAuthor(n.account);
|
|
213
|
+
if (!actor || this.store.isBlocked(actor)) continue;
|
|
214
|
+
// The post a favourite or boost is about is one of this account's own,
|
|
215
|
+
// so it is mirrored first — a notification pointing at nothing is a
|
|
216
|
+
// row the client drops.
|
|
217
|
+
if (n.status?.uri) this._mirror(n.status, { acct: rec.id, parents });
|
|
218
|
+
if (n.type === 'favourite' || n.type === 'reblog') {
|
|
219
|
+
this.store.addNotification({
|
|
220
|
+
type: n.type === 'favourite' ? 'favourite' : 'reblog',
|
|
221
|
+
actor, noteId: n.status?.uri, via: rec.id,
|
|
222
|
+
});
|
|
223
|
+
} else if (n.type === 'follow' || n.type === 'follow_request') {
|
|
224
|
+
this.store.addNotification({ type: 'follow', actor, via: rec.id });
|
|
225
|
+
await this.onNotification?.(n, { actor, acct: rec.id });
|
|
226
|
+
} else if (n.type === 'mention') {
|
|
227
|
+
this.store.addNotification({ type: 'mention', actor, noteId: n.status?.uri, via: rec.id });
|
|
228
|
+
await this.onNotification?.(n, { actor, acct: rec.id });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (notes.length && notes[0]?.id) this.setMarks(rec.id, { notifSinceId: String(notes[0].id) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
st.failures = 0;
|
|
235
|
+
return added;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Two caps. The per-account one keeps a busy account from crowding out a
|
|
239
|
+
// quiet one; the total keeps every connected account together from evicting
|
|
240
|
+
// the pod's own posts and the timeline it already had.
|
|
241
|
+
_prune() {
|
|
242
|
+
const all = this.store.getStatuses();
|
|
243
|
+
const mine = all.filter(s => s.kind === 'acct');
|
|
244
|
+
if (!mine.length) return;
|
|
245
|
+
const drop = new Set();
|
|
246
|
+
const seen = new Map();
|
|
247
|
+
for (const s of mine) {
|
|
248
|
+
const acct = s.sourceAccts?.[0]?.acct || '?';
|
|
249
|
+
const n = (seen.get(acct) || 0) + 1;
|
|
250
|
+
seen.set(acct, n);
|
|
251
|
+
if (n > MAX_PER_ACCT) drop.add(s.noteId);
|
|
252
|
+
}
|
|
253
|
+
for (const s of mine.slice(MAX_ACCT_ENTRIES)) drop.add(s.noteId);
|
|
254
|
+
if (!drop.size) return;
|
|
255
|
+
this.store.write('statuses.json', all.filter(s => !drop.has(s.noteId)));
|
|
256
|
+
}
|
|
257
|
+
}
|
package/lib/admin.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from
|
|
|
27
27
|
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
|
|
28
28
|
import { normalizeImport, IMPORT_KINDS } from './import.mjs';
|
|
29
29
|
import { insecureUrlReason } from './safefetch.mjs';
|
|
30
|
-
import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
|
|
30
|
+
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from './setup.mjs';
|
|
31
31
|
import { portFree, freePortFrom } from './ports.mjs';
|
|
32
32
|
import { claimDirectory, yieldDirectory } from './directory.mjs';
|
|
33
33
|
import { localFetch } from './localapi.mjs';
|
|
@@ -39,6 +39,38 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
|
|
|
39
39
|
const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
|
|
40
40
|
|
|
41
41
|
const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
|
|
42
|
+
// The vendored client is upstream's build, byte for byte, so the integrity
|
|
43
|
+
// check can say so. The two things it needs changed happen on the way out
|
|
44
|
+
// instead of in the files.
|
|
45
|
+
//
|
|
46
|
+
// vite-plugin-pwa injects this registration into every page it builds. The
|
|
47
|
+
// worker it installs answers navigations from a precache and replays the
|
|
48
|
+
// headers stored at install time, so a CSP change can never reach a browser
|
|
49
|
+
// that already has one.
|
|
50
|
+
const SW_REGISTER = /<script id="vite-plugin-pwa:inline-sw">[\s\S]*?<\/script>/i;
|
|
51
|
+
// Removing it stops new installs. This replaces the worker itself, so the ones
|
|
52
|
+
// already out there clean up: browsers re-fetch sw.js on navigation and
|
|
53
|
+
// install what they find. No fetch handler on purpose — a worker without one
|
|
54
|
+
// never intercepts a request, so pages go straight to the network while it
|
|
55
|
+
// runs.
|
|
56
|
+
const SW_KILL = `self.addEventListener('install', () => self.skipWaiting());
|
|
57
|
+
|
|
58
|
+
self.addEventListener('activate', (event) => {
|
|
59
|
+
event.waitUntil((async () => {
|
|
60
|
+
for (const key of await caches.keys()) await caches.delete(key);
|
|
61
|
+
await self.registration.unregister();
|
|
62
|
+
for (const client of await self.clients.matchAll({ type: 'window' })) {
|
|
63
|
+
try { await client.navigate(client.url); } catch { /* tab will refresh on its own */ }
|
|
64
|
+
}
|
|
65
|
+
})());
|
|
66
|
+
});
|
|
67
|
+
`;
|
|
68
|
+
// Null rather than the original when the tag is gone: an upstream change that
|
|
69
|
+
// silently no-opped here would put the worker back, which is the whole thing
|
|
70
|
+
// this exists to prevent.
|
|
71
|
+
function stripSwRegistration(html) {
|
|
72
|
+
return SW_REGISTER.test(html) ? html.replace(SW_REGISTER, '') : null;
|
|
73
|
+
}
|
|
42
74
|
const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
|
|
43
75
|
// Our own pages, kept out of ui/ for two reasons: a client dist dropped in
|
|
44
76
|
// there under the same name would shadow them, and a group serves these and
|
|
@@ -80,17 +112,46 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package
|
|
|
80
112
|
// /shutdown is here because stopping an agent that was never set up is exactly
|
|
81
113
|
// the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
|
|
82
114
|
// only to this machine.
|
|
83
|
-
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
|
|
115
|
+
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
|
|
116
|
+
|
|
117
|
+
// The page the other server's redirect lands on. Self-contained on purpose:
|
|
118
|
+
// the browser arrives here from somewhere else, and nothing may load from
|
|
119
|
+
// that somewhere.
|
|
120
|
+
function callbackPage(ok, msg) {
|
|
121
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
122
|
+
const head = ok ? 'Account connected' : 'Not connected';
|
|
123
|
+
return `<!doctype html>
|
|
124
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
126
|
+
<title>${head}</title>
|
|
127
|
+
<style>
|
|
128
|
+
:root { color-scheme: light dark; }
|
|
129
|
+
body { font: 16px/1.7 system-ui, -apple-system, sans-serif; margin: 0;
|
|
130
|
+
padding: 2.5rem 2rem; background: #ffffff; color: #1a1a1a; }
|
|
131
|
+
main { max-width: 32rem; margin: 0 auto; }
|
|
132
|
+
h1 { font-size: 1.375rem; font-weight: 500; margin: 0 0 1rem; }
|
|
133
|
+
p { margin: 0 0 1rem; }
|
|
134
|
+
@media (prefers-color-scheme: dark) {
|
|
135
|
+
body { background: #16161a; color: #ececf0; }
|
|
136
|
+
}
|
|
137
|
+
</style></head>
|
|
138
|
+
<body><main>
|
|
139
|
+
<h1>${head}</h1>
|
|
140
|
+
<p>${esc(msg)}</p>
|
|
141
|
+
<p>You can close this tab and go back to FediPod.</p>
|
|
142
|
+
</main></body></html>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
84
145
|
// Routes that manage a local agent process — spawning siblings, killing this
|
|
85
146
|
// one, moving files on the machine, running setup in a browser. Inside a pod
|
|
86
147
|
// server there is no such process and no such machine: identities come from
|
|
87
148
|
// the server's own configuration, so these are not there to be found.
|
|
88
149
|
const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
|
|
89
|
-
'/state-move', '/setup', '/setup/check']);
|
|
150
|
+
'/state-move', '/setup', '/setup/check', '/setup/reset']);
|
|
90
151
|
// AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
|
|
91
152
|
// fediverse is welcome there; creating accounts and editing the record is for
|
|
92
153
|
// whoever is sitting at the machine.
|
|
93
|
-
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/gateway', '/alias', '/import', '/update']);
|
|
154
|
+
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']);
|
|
94
155
|
// The identity itself. Changing any of these means a different actor at a
|
|
95
156
|
// different address, which is a new setup, not an edit.
|
|
96
157
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
@@ -118,7 +179,10 @@ function inlineScriptHashes() {
|
|
|
118
179
|
if (inlineHashes) return inlineHashes;
|
|
119
180
|
inlineHashes = [];
|
|
120
181
|
try {
|
|
121
|
-
|
|
182
|
+
// The page as it is SERVED, not as it sits on disk: hashing the
|
|
183
|
+
// registration script we strip would allow a script nobody gets.
|
|
184
|
+
const raw = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
|
|
185
|
+
const html = stripSwRegistration(raw) ?? raw;
|
|
122
186
|
for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
123
187
|
const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
|
|
124
188
|
inlineHashes.push(`'sha256-${digest}'`);
|
|
@@ -296,7 +360,23 @@ function sendFile(res, baseDir, rel, auth) {
|
|
|
296
360
|
res.writeHead(403); res.end(); return true;
|
|
297
361
|
}
|
|
298
362
|
const ext = path.extname(real);
|
|
299
|
-
|
|
363
|
+
let body = fs.readFileSync(real);
|
|
364
|
+
// The vendored client, adjusted on the way out rather than in the files.
|
|
365
|
+
if (real.startsWith(fs.realpathSync(PHANPY_DIR))) {
|
|
366
|
+
if (path.basename(real) === 'sw.js') {
|
|
367
|
+
body = Buffer.from(SW_KILL);
|
|
368
|
+
} else if (ext === '.html') {
|
|
369
|
+
const stripped = stripSwRegistration(body.toString('utf8'));
|
|
370
|
+
if (stripped === null) {
|
|
371
|
+
console.error(`refusing to serve ${path.basename(real)}: no service-worker `
|
|
372
|
+
+ 'registration to remove — the vendored client changed shape');
|
|
373
|
+
res.writeHead(500, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
|
|
374
|
+
res.end('the bundled client changed shape; refusing to serve it\n');
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
body = Buffer.from(stripped);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
300
380
|
// Our own pages are read straight off disk and change whenever the project
|
|
301
381
|
// does. With no cache headers a browser is free to reuse them without
|
|
302
382
|
// asking, so an edited page keeps rendering the old one and looks like the
|
|
@@ -511,6 +591,32 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
511
591
|
if (req.method === 'GET' && p === '/blocks') return json(res, 200, agent.store.getBlocklist());
|
|
512
592
|
if (req.method === 'GET' && p === '/log') return json(res, 200, { lines: agent.logLines(200) });
|
|
513
593
|
if (req.method === 'GET' && p === '/deadletter') return json(res, 200, { items: agent.store.getDeadLetters() });
|
|
594
|
+
if (req.method === 'GET' && p === '/fediacct') {
|
|
595
|
+
return json(res, 200, { accounts: agent.fediaccts?.status() || [] });
|
|
596
|
+
}
|
|
597
|
+
// Where the other server sends the browser back. A GET, so it gets none
|
|
598
|
+
// of the POST branch's local-only protection for free and asks for its
|
|
599
|
+
// own — a code arriving from anywhere else is not the owner's.
|
|
600
|
+
if (req.method === 'GET' && p === '/fediacct/callback') {
|
|
601
|
+
if (!embedded && !allowed.isLocalRequest(req)) {
|
|
602
|
+
return json(res, 403, { error: 'connecting an account is available on this machine only' });
|
|
603
|
+
}
|
|
604
|
+
const q = new URL(req.url, 'https://x.invalid').searchParams;
|
|
605
|
+
const done = (ok, msg) => {
|
|
606
|
+
res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' });
|
|
607
|
+
res.end(callbackPage(ok, msg));
|
|
608
|
+
};
|
|
609
|
+
if (q.get('error')) return done(false, q.get('error_description') || q.get('error'));
|
|
610
|
+
if (!q.get('code') || !q.get('state')) return done(false, 'that sign-in came back incomplete');
|
|
611
|
+
try {
|
|
612
|
+
const row = await agent.fediaccts.complete({ state: q.get('state'), code: q.get('code') });
|
|
613
|
+
const cfg = agent.store.getConfig();
|
|
614
|
+
agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
|
|
615
|
+
await agent.store.flush();
|
|
616
|
+
agent.restartAccts?.();
|
|
617
|
+
return done(true, `${row.handle} is connected.`);
|
|
618
|
+
} catch (e) { return done(false, e.message); }
|
|
619
|
+
}
|
|
514
620
|
if (req.method === 'GET' && p === '/tagfeed') {
|
|
515
621
|
return json(res, 200, agent.tagfeed
|
|
516
622
|
? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
|
|
@@ -613,6 +719,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
613
719
|
atproto: cfg.atproto
|
|
614
720
|
? { ...cfg.atproto, connected: !!agent.atproto?.connected() }
|
|
615
721
|
: null,
|
|
722
|
+
// Connected fediverse accounts, from the credentials themselves
|
|
723
|
+
// rather than from config — a roster entry whose file was deleted
|
|
724
|
+
// should stop being listed, not linger.
|
|
725
|
+
fediAccounts: agent.fediaccts?.status() || [],
|
|
616
726
|
origins: {
|
|
617
727
|
loopback: publicOrigin
|
|
618
728
|
|| `https://localhost:${port}/`,
|
|
@@ -738,6 +848,34 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
738
848
|
}
|
|
739
849
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
740
850
|
case '/setup/check': return json(res, 200, preflight(body));
|
|
851
|
+
// Discard a credential that never finished setup, so the account and
|
|
852
|
+
// pod can be entered again. The credential a CSS server mints is shown
|
|
853
|
+
// once, so a setup that stops after the mint (a wrong pod answers 401
|
|
854
|
+
// to the first write) leaves the form in "finish" mode with no way to
|
|
855
|
+
// re-enter what was wrong. This removes it locally and reopens the full
|
|
856
|
+
// form. It does NOT revoke server-side — that needs the account
|
|
857
|
+
// password (`fedipod revoke-credential`); the old credential is left on
|
|
858
|
+
// the account, revocable from its dashboard.
|
|
859
|
+
case '/setup/reset': {
|
|
860
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
861
|
+
// A working identity is never swapped out this way — that is a
|
|
862
|
+
// teardown (`fedipod retire`), not a half-finished setup.
|
|
863
|
+
if (agent.configured()) {
|
|
864
|
+
return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
|
|
865
|
+
}
|
|
866
|
+
if (setupRun?.phase === 'running') {
|
|
867
|
+
return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
|
|
868
|
+
}
|
|
869
|
+
const home = agent.home;
|
|
870
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
|
|
871
|
+
const removed = hasCredential(home);
|
|
872
|
+
if (removed) fs.rmSync(credentialPath(home), { force: true });
|
|
873
|
+
// Drop the pod handle too, so configured() cannot flicker true off a
|
|
874
|
+
// stale in-memory session while the fresh form is filled in.
|
|
875
|
+
agent.remote = null;
|
|
876
|
+
setupRun = null;
|
|
877
|
+
return json(res, 200, { ok: true, removed });
|
|
878
|
+
}
|
|
741
879
|
case '/setup': {
|
|
742
880
|
// A visited page must not be able to navigate this into existence.
|
|
743
881
|
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
@@ -1304,6 +1442,38 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1304
1442
|
await agent.store.flush();
|
|
1305
1443
|
return json(res, 200, { ok: true, atproto: { ...cfg.atproto, crossPost: !!body.crossPost } });
|
|
1306
1444
|
}
|
|
1445
|
+
case '/fediacct/connect': {
|
|
1446
|
+
// Starts the sign-in at the other server. Loopback-only, and the
|
|
1447
|
+
// redirect returns to the origin the owner is actually using, not a
|
|
1448
|
+
// guess — a client reaches this agent by several names.
|
|
1449
|
+
if (!body.host) return json(res, 400, { error: 'the address of the server is required' });
|
|
1450
|
+
try {
|
|
1451
|
+
const { url } = await agent.fediaccts.begin({
|
|
1452
|
+
host: body.host, redirectUri: `https://${req.headers.host}/fediacct/callback`,
|
|
1453
|
+
});
|
|
1454
|
+
return json(res, 200, { ok: true, authorize: url });
|
|
1455
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
1456
|
+
}
|
|
1457
|
+
case '/fediacct/disconnect': {
|
|
1458
|
+
if (!body.id) return json(res, 400, { error: 'id required' });
|
|
1459
|
+
if (!agent.fediaccts.remove(body.id)) return json(res, 404, { error: 'no such account' });
|
|
1460
|
+
const cfg = agent.store.getConfig();
|
|
1461
|
+
agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
|
|
1462
|
+
await agent.store.flush();
|
|
1463
|
+
agent.restartAccts?.();
|
|
1464
|
+
return json(res, 200, { ok: true });
|
|
1465
|
+
}
|
|
1466
|
+
case '/fediacct': {
|
|
1467
|
+
// Non-secret settings only — today that is whether it is polled.
|
|
1468
|
+
if (!body.id) return json(res, 400, { error: 'id required' });
|
|
1469
|
+
const row = agent.fediaccts.setEnabled(body.id, !!body.enabled);
|
|
1470
|
+
if (!row) return json(res, 404, { error: 'no such account' });
|
|
1471
|
+
const cfg = agent.store.getConfig();
|
|
1472
|
+
agent.store.setConfig({ ...cfg, fediAccounts: agent.fediaccts.roster() });
|
|
1473
|
+
await agent.store.flush();
|
|
1474
|
+
agent.restartAccts?.();
|
|
1475
|
+
return json(res, 200, { ok: true, account: row });
|
|
1476
|
+
}
|
|
1307
1477
|
case '/archive': {
|
|
1308
1478
|
// Whether drained mail's original bytes are kept in the private
|
|
1309
1479
|
// half's inbox-archive/. Absent means on.
|
package/lib/bskygroup.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export class BskyGroup {
|
|
|
30
30
|
// ONE reply ever: tell an unbridged joiner how to reach the fediverse side.
|
|
31
31
|
async nudge({ did, handle }) {
|
|
32
32
|
const text = `@${handle} welcome! Follow @ap.brid.gy and your posts will reach `
|
|
33
|
-
+ 'the
|
|
33
|
+
+ 'the Fediverse side of this group too.';
|
|
34
34
|
const start = 0;
|
|
35
35
|
const rec = this.atproto.read();
|
|
36
36
|
await this.atproto.xrpc('com.atproto.repo.createRecord', {
|
package/lib/embed.mjs
CHANGED
|
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
|
|
|
210
210
|
]);
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
// podHome and actorUrl are the identity's own locations on the pod. They are
|
|
214
|
+
// returned rather than rebuilt by the caller so the root name lives here.
|
|
215
|
+
return {
|
|
216
|
+
agent, handle, home, surface, host: authorities.host,
|
|
217
|
+
podHome: urls.home, actorUrl: urls.actor, stop,
|
|
218
|
+
};
|
|
214
219
|
}
|