fedipod-bb 0.2.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 +195 -0
- package/bin/fedipod-bb.mjs +113 -0
- package/fep-draft.md +110 -0
- package/package.json +26 -0
- package/site/bb.js +1644 -0
- package/site/index.html +301 -0
- package/site/markdown.mjs +0 -0
- package/site/masto.mjs +179 -0
- package/site/mine.mjs +48 -0
- package/site/oidc-session.mjs +6 -0
- package/site/pod.mjs +394 -0
- package/site/private.mjs +98 -0
- package/site/read.mjs +294 -0
- package/site/seen.mjs +64 -0
- package/src/access.mjs +79 -0
- package/src/credential.mjs +37 -0
- package/src/forum-agent.mjs +991 -0
- package/src/forum-intake.mjs +112 -0
- package/src/index.mjs +9 -0
- package/src/moderation.mjs +303 -0
- package/src/provision.mjs +37 -0
- package/src/publish.mjs +244 -0
- package/src/run.mjs +52 -0
- package/src/settings.mjs +186 -0
- package/src/topics.mjs +165 -0
- package/src/urls.mjs +90 -0
- package/src/wire.mjs +114 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
// forum-agent.mjs — the forum's host: one process that runs a FediPod group
|
|
2
|
+
// agent for every category, drains the forum's one inbox and routes each
|
|
3
|
+
// activity to the category it names, places carried posts into topics, and
|
|
4
|
+
// holds one lease for the whole forum so several moderators' devices can
|
|
5
|
+
// share the job — whichever is up acts, the rest watch and take over.
|
|
6
|
+
//
|
|
7
|
+
// What is reused is the whole group: a category's store, publisher,
|
|
8
|
+
// deliverer and intake are FediPod's own, configured as a group. What is new
|
|
9
|
+
// is the routing, the topic placement, and the lifecycle around N of them.
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { PodStore } from 'fedipod/core/store.mjs';
|
|
15
|
+
import { storageFor as defaultStorageFor } from 'fedipod/core/storage.mjs';
|
|
16
|
+
import { Deliverer } from 'fedipod/core/deliver.mjs';
|
|
17
|
+
import { Publisher } from 'fedipod/core/publisher/index.mjs';
|
|
18
|
+
import { Intake } from 'fedipod/core/intake/index.mjs';
|
|
19
|
+
import { C2S } from 'fedipod/client/c2s.mjs';
|
|
20
|
+
import { Lease } from 'fedipod/core/lease.mjs';
|
|
21
|
+
import { resolveKeys } from 'fedipod/core/keys.mjs';
|
|
22
|
+
import { assertionKeyId, orderedCollection } from 'fedipod/core/wire.mjs';
|
|
23
|
+
import * as podFeatured from 'fedipod/pod/featured.mjs';
|
|
24
|
+
import { resolveHandle } from 'fedipod/core/social.mjs';
|
|
25
|
+
import * as podInbox from 'fedipod/pod/inbox.mjs';
|
|
26
|
+
import { forumUrls, ROOT, isSlug } from './urls.mjs';
|
|
27
|
+
import * as topics from './topics.mjs';
|
|
28
|
+
import * as publish from './publish.mjs';
|
|
29
|
+
import * as moderation from './moderation.mjs';
|
|
30
|
+
import * as access from './access.mjs';
|
|
31
|
+
import { provisionForum, provisionCategory } from './provision.mjs';
|
|
32
|
+
import * as settings from './settings.mjs';
|
|
33
|
+
|
|
34
|
+
import { ForumIntake } from './forum-intake.mjs';
|
|
35
|
+
export { ForumIntake };
|
|
36
|
+
|
|
37
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
38
|
+
const arr =(v) => (v === undefined || v === null ? [] : [].concat(v));
|
|
39
|
+
const HEARTBEAT_MS = 10 * 60_000;
|
|
40
|
+
// How often a moderator's ask is looked at again. Short enough that asking for
|
|
41
|
+
// something and watching it happen feels like one act.
|
|
42
|
+
const ASK_SWEEP_MS = 60_000;
|
|
43
|
+
const VIEWER_REFRESH_MS = 5 * 60_000;
|
|
44
|
+
|
|
45
|
+
export class ForumAgent {
|
|
46
|
+
// `remote` and `storageFor` are injectable so a test can stand a pod in.
|
|
47
|
+
constructor({ home, log = () => {}, remote = null, storageFor = defaultStorageFor, push = true, pollSeconds = null }) {
|
|
48
|
+
this.home = home;
|
|
49
|
+
this.log = log;
|
|
50
|
+
this.remote = remote;
|
|
51
|
+
this.storageFor = storageFor;
|
|
52
|
+
this.push = push;
|
|
53
|
+
this.pollSeconds = pollSeconds;
|
|
54
|
+
this.categories = [];
|
|
55
|
+
this.viewer = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// This installation's name for itself, minted once and kept beside the
|
|
59
|
+
// credential. The lease lets a holder reclaim its own without waiting, so
|
|
60
|
+
// whether this is stable decides whether a RESTART is the same host coming
|
|
61
|
+
// back or a second one arriving: left to the lease's own default it was a
|
|
62
|
+
// fresh name every time, the old lease still had minutes of its five to run,
|
|
63
|
+
// and the forum came back up read-only — not draining its inbox, not applying
|
|
64
|
+
// a moderator's ask — until it expired. Which is what a restart to pick up a
|
|
65
|
+
// fix looked like from the outside: the fix changing nothing.
|
|
66
|
+
hostId() {
|
|
67
|
+
const at = path.join(this.home, 'host-id');
|
|
68
|
+
try { return fs.readFileSync(at, 'utf8').trim() || null; } catch { /* not yet */ }
|
|
69
|
+
try {
|
|
70
|
+
const made = crypto.randomUUID();
|
|
71
|
+
fs.writeFileSync(at, made + '\n', { mode: 0o600 });
|
|
72
|
+
return made;
|
|
73
|
+
} catch {
|
|
74
|
+
// Unwritable: a fresh name each time is honest, since this host cannot
|
|
75
|
+
// prove it is the one that held the lease.
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
readCredential() {
|
|
81
|
+
try { return JSON.parse(fs.readFileSync(path.join(this.home, 'credential.json'), 'utf8')); }
|
|
82
|
+
catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
status() {
|
|
86
|
+
return {
|
|
87
|
+
mode: !this.store ? 'unconfigured' : this.viewer ? 'viewer' : 'active',
|
|
88
|
+
handle: this.config?.handle || null,
|
|
89
|
+
actor: this.site?.actor || null,
|
|
90
|
+
categories: this.categories.map(c => ({
|
|
91
|
+
slug: c.slug, actor: c.urls.actor,
|
|
92
|
+
members: c.store.getContacts().followers.length,
|
|
93
|
+
topics: topics.list(c.store).length,
|
|
94
|
+
queue: c.store.getQueue().length,
|
|
95
|
+
})),
|
|
96
|
+
inbox: this.intake?.inboxStats || null,
|
|
97
|
+
lastDrain: this.intake?.lastDrain || null,
|
|
98
|
+
push: this.intake?.wsState || 'n/a',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The first act on a fresh pod: the forum's containers, its config, and
|
|
103
|
+
// nothing else — the actors are published on the first connect.
|
|
104
|
+
async init({ handle, name, categories = [], moderators = [], moderatorWebIds = [],
|
|
105
|
+
membersOnly = [], memberWebIds = {}, approveJoins = false, review = false, replyPolicy = 'open' }) {
|
|
106
|
+
const cred = this.readCredential();
|
|
107
|
+
if (!cred) throw new Error('no credential.json — make one first');
|
|
108
|
+
await this.attachRemote(cred);
|
|
109
|
+
const site = forumUrls(cred.remotePod, cred.root || ROOT);
|
|
110
|
+
await provisionForum(this.remote, site);
|
|
111
|
+
const store = new PodStore({ log: this.log });
|
|
112
|
+
store.attach(this.storageFor(site.state, (u, i) => this.remote.fetch(u, i)));
|
|
113
|
+
await store.load().catch(() => {});
|
|
114
|
+
const existing = store.getConfig() || {};
|
|
115
|
+
const cats = categories.map(c => (typeof c === 'string' ? { slug: c, name: c } : c));
|
|
116
|
+
for (const c of cats) if (!isSlug(c.slug)) throw new Error(`not a category slug: ${c.slug}`);
|
|
117
|
+
// A renamed forum has to be published again: the profile is written only
|
|
118
|
+
// when its digest changed or something asks, and a name lives in the
|
|
119
|
+
// actor document, not in the config alone.
|
|
120
|
+
// A changed name — the forum's or any category's — lives in a published
|
|
121
|
+
// actor, and the profile is only rewritten when its digest changed or
|
|
122
|
+
// something asks. So it asks.
|
|
123
|
+
const was = JSON.stringify((existing.categories || []).map(c => [c.slug, c.name]));
|
|
124
|
+
// Who may read what is written into the pod's own access rules, which are
|
|
125
|
+
// only written when something asks for a republish — so a change to them
|
|
126
|
+
// asks for one.
|
|
127
|
+
const access = JSON.stringify([existing.membersOnly || [], existing.memberWebIds || {}, existing.moderatorWebIds || []]);
|
|
128
|
+
const accessChanged = !!existing.handle && access !== JSON.stringify([membersOnly, memberWebIds, moderatorWebIds]);
|
|
129
|
+
const renamed = !!existing.handle
|
|
130
|
+
&& ((name || handle) !== existing.name || was !== JSON.stringify(cats.map(c => [c.slug, c.name])));
|
|
131
|
+
store.setConfig({
|
|
132
|
+
...existing, kind: 'application', handle, name: name || existing.name || handle,
|
|
133
|
+
...(renamed || accessChanged ? { republish: true } : {}),
|
|
134
|
+
...(accessChanged ? { reprovision: true } : {}),
|
|
135
|
+
remotePod: cred.remotePod, root: cred.root || ROOT,
|
|
136
|
+
categories: cats, moderators, moderatorWebIds, membersOnly, memberWebIds,
|
|
137
|
+
approveJoins, review, replyPolicy,
|
|
138
|
+
});
|
|
139
|
+
await store.flush();
|
|
140
|
+
this.log(`forum ${handle} initialised with ${cats.length} categor${cats.length === 1 ? 'y' : 'ies'}`);
|
|
141
|
+
return store.getConfig();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async attachRemote(cred) {
|
|
145
|
+
if (this.remote) return;
|
|
146
|
+
const { RemotePod } = await import('fedipod/remote');
|
|
147
|
+
this.remote = new RemotePod(cred, { log: this.log, home: this.home });
|
|
148
|
+
await this.remote.warmup();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async connect({ act = true } = {}) {
|
|
152
|
+
const cred = this.readCredential();
|
|
153
|
+
if (!cred) return false;
|
|
154
|
+
await this.attachRemote(cred);
|
|
155
|
+
// The forum's config says whether it is fronted; the config is on the pod
|
|
156
|
+
// at a place only the pod's own address names, so it is read first.
|
|
157
|
+
const plain = forumUrls(cred.remotePod, cred.root || ROOT);
|
|
158
|
+
this.store = new PodStore({ log: this.log });
|
|
159
|
+
this.store.attach(this.storageFor(plain.state, (u, i) => this.remote.fetch(u, i)));
|
|
160
|
+
await this.store.load();
|
|
161
|
+
this.config = this.store.getConfig();
|
|
162
|
+
if (!this.config) { this.log('credential present but the forum has no config — run init'); return false; }
|
|
163
|
+
const front = this.config.gateway?.front || null;
|
|
164
|
+
this.site = forumUrls(cred.remotePod, cred.root || ROOT, front ? { front, handle: this.config.handle } : {});
|
|
165
|
+
this.lease = new Lease({ url: this.site.state + 'lease.json', fetchImpl: (u, i) => this.remote.fetch(u, i), log: this.log,
|
|
166
|
+
id: this.hostId() });
|
|
167
|
+
this.viewer = act ? !(await this.lease.acquire()) : true;
|
|
168
|
+
this.categories = [];
|
|
169
|
+
for (const c of this.config.categories || []) this.categories.push(await this.buildCategory(c));
|
|
170
|
+
this.siteAgent = await this.buildSite();
|
|
171
|
+
// One map for every fronted id on this pod. Each Publisher installed its
|
|
172
|
+
// own on construction, and the last would have been the only one; the
|
|
173
|
+
// forum's covers the site and every category.
|
|
174
|
+
if (this.site.toPod && this.remote.setUrlMap) this.remote.setUrlMap((u) => this.toPod(u));
|
|
175
|
+
if (!act) return true;
|
|
176
|
+
if (this.viewer) {
|
|
177
|
+
this.startViewer();
|
|
178
|
+
this.log(`another device hosts ${this.config.handle} — viewing`);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
await this.startActive();
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Any advertised id on this pod, fronted or not, to where it is written.
|
|
186
|
+
toPod(u) {
|
|
187
|
+
if (typeof u !== 'string') return u;
|
|
188
|
+
// A category with no front has no mapper; an address it does not map
|
|
189
|
+
// stays itself. Returning nothing here made every address the same as
|
|
190
|
+
// every other to a comparison of mapped addresses.
|
|
191
|
+
for (const cat of this.categories) { const m = cat.urls.toPod?.(u); if (m != null && m !== u) return m; }
|
|
192
|
+
return this.site.toPod ? this.site.toPod(u) : u;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One FediPod group per category, its config written from the forum's.
|
|
196
|
+
async buildCategory({ slug, name, summary = null }) {
|
|
197
|
+
const urls = this.site.category(slug);
|
|
198
|
+
const store = new PodStore({ log: this.log });
|
|
199
|
+
store.attach(this.storageFor(urls.state, (u, i) => this.remote.fetch(u, i)));
|
|
200
|
+
await store.load().catch(() => {});
|
|
201
|
+
const prior = store.getConfig() || {};
|
|
202
|
+
const gw = this.config.gateway;
|
|
203
|
+
const config = {
|
|
204
|
+
...prior,
|
|
205
|
+
kind: 'group', handle: slug, name: name || slug, ...(summary ? { summary } : {}),
|
|
206
|
+
remotePod: this.site.base, root: this.site.root + 'c/' + slug + '/',
|
|
207
|
+
forum: this.site.actor, inboxUrl: this.site.inbox,
|
|
208
|
+
moderators: this.config.moderators || [],
|
|
209
|
+
// A private category NEVER carries to someone it cannot let read: every
|
|
210
|
+
// join waits for a moderator, and admitting one requires a WebID the
|
|
211
|
+
// pod can grant. Without this a follower on a server with no WebID —
|
|
212
|
+
// Mastodon, Lemmy — would be sent the posts of a category whose pages
|
|
213
|
+
// they are refused, which is not private at all.
|
|
214
|
+
approveJoins: !!this.config.approveJoins || (this.config.membersOnly || []).includes(slug),
|
|
215
|
+
// Private: the posts are written for the people named and nobody else,
|
|
216
|
+
// so the category carries what is addressed to IT rather than to the
|
|
217
|
+
// world. An ordinary group refuses that, and is right to.
|
|
218
|
+
private: (this.config.membersOnly || []).includes(slug),
|
|
219
|
+
review: !!this.config.review,
|
|
220
|
+
// Fronted: the category's door at the Gateway, and its front id. The
|
|
221
|
+
// secret is the row's own, handed over once at attach.
|
|
222
|
+
...(gw?.front ? { gateway: {
|
|
223
|
+
url: `${gw.front}/u/${slug}/ap/inbox/`, frontActor: `${gw.front}/u/${slug}/ap/actor`,
|
|
224
|
+
mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[slug] || prior.gateway?.hmacSecret || null,
|
|
225
|
+
} } : {}),
|
|
226
|
+
};
|
|
227
|
+
store.setConfig(config);
|
|
228
|
+
const keys = await resolveKeys(store, { localDir: null, actorId: urls.actor, log: this.log });
|
|
229
|
+
const cat = { slug, urls, store, config, remote: this.remote };
|
|
230
|
+
cat.deliverer = new Deliverer({
|
|
231
|
+
store, rsaPrivate: keys.rsaPrivate, keyId: urls.actor + '#main-key', actorId: urls.actor,
|
|
232
|
+
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true,
|
|
233
|
+
onGone: () => cat.publisher.publishCollections({ followers: true }),
|
|
234
|
+
});
|
|
235
|
+
cat.publisher = new Publisher({
|
|
236
|
+
config, remote: this.remote, store, deliverer: cat.deliverer,
|
|
237
|
+
publicKeyPem: keys.rsaPublicPem, assertionKey: keys.edPublicMultibase, log: this.log,
|
|
238
|
+
resolveMention: (h) => resolveHandle(cat, h),
|
|
239
|
+
resolveActor: (u) => cat.intake.fetchAP(u),
|
|
240
|
+
probeFetch: this.probeFetch || null,
|
|
241
|
+
});
|
|
242
|
+
cat.intake = new Intake({
|
|
243
|
+
config, urls, remote: this.remote, store, deliverer: cat.deliverer, publisher: cat.publisher,
|
|
244
|
+
log: this.log, lease: this.lease, push: false,
|
|
245
|
+
});
|
|
246
|
+
cat.intake.recentNotes = new Map();
|
|
247
|
+
// A private category's posts sit behind their authors' own access rules,
|
|
248
|
+
// and a Solid pod has never heard of an HTTP signature. So when the
|
|
249
|
+
// ordinary fetch is refused, ask again as the forum's own WebID — the one
|
|
250
|
+
// every member's rule names, and the reason the forum is on that list.
|
|
251
|
+
const signedFetchAP = cat.intake.fetchAP.bind(cat.intake);
|
|
252
|
+
cat.intake.fetchAP = async (url) => {
|
|
253
|
+
const doc = await signedFetchAP(url);
|
|
254
|
+
if (doc || !(this.config.membersOnly || []).includes(slug)) return doc;
|
|
255
|
+
return this.podFetchAP(url);
|
|
256
|
+
};
|
|
257
|
+
cat.intake.onCarried = (ev) => this.onCarried(cat, ev);
|
|
258
|
+
cat.intake.onReport = (activity, actor, opts) => cat.intake.queueModeration(activity, actor, opts);
|
|
259
|
+
// What happens to a post from somebody who has not joined: dropped, or
|
|
260
|
+
// held where a moderator will see it (the category's `replyPolicy`).
|
|
261
|
+
cat.intake.onStranger = (ev) => this.onStranger(cat, ev);
|
|
262
|
+
cat.intake.onCarriedEdit = (ev) => this.onCarriedEdit(cat, ev);
|
|
263
|
+
cat.intake.onCarriedGone = (ev) => this.onCarriedGone(cat, ev);
|
|
264
|
+
cat.intake.isModerationAskExtra = (a) => moderation.isForumAsk(cat, a);
|
|
265
|
+
cat.configured = () => true;
|
|
266
|
+
cat.log = this.log;
|
|
267
|
+
cat.c2s = new C2S({ agent: cat, log: this.log });
|
|
268
|
+
return cat;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Who moderates, as the forum publishes it. A category writes its own list
|
|
272
|
+
// when its profile is published; this is the forum's, which the website
|
|
273
|
+
// reads, and nothing but a full publish used to rewrite it — so a moderator
|
|
274
|
+
// added while the forum ran did not appear until it was next started.
|
|
275
|
+
async republishAdministrators() {
|
|
276
|
+
const mods = this.config.moderators || [];
|
|
277
|
+
await publish.publishAdministrators(this.siteAgent, mods, { force: true });
|
|
278
|
+
// Every category's list is the forum's, written now rather than at the
|
|
279
|
+
// next start, and kept in the category's own settings so its queue and
|
|
280
|
+
// its actor agree with it.
|
|
281
|
+
for (const cat of this.categories) {
|
|
282
|
+
cat.config.moderators = mods;
|
|
283
|
+
cat.store.setConfig({ ...cat.store.getConfig(), moderators: mods });
|
|
284
|
+
await podFeatured.writeModerators(this.remote, cat.urls, orderedCollection(cat.urls.moderators, mods))
|
|
285
|
+
.catch(e => this.log(`moderators of ${cat.slug}: ${e.message}`));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The forum's own actor: an Application that answers for the site, whose
|
|
290
|
+
// inbox is the one every category names. Its store is the forum's.
|
|
291
|
+
async buildSite() {
|
|
292
|
+
const { site, store } = this;
|
|
293
|
+
const gw = this.config.gateway;
|
|
294
|
+
const config = {
|
|
295
|
+
...this.config, kind: 'application', root: this.site.root, remotePod: this.site.base,
|
|
296
|
+
...(gw?.front ? { gateway: {
|
|
297
|
+
url: `${gw.front}/u/${this.config.handle}/ap/inbox/`, frontActor: `${gw.front}/u/${this.config.handle}/ap/actor`,
|
|
298
|
+
mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[this.config.handle] || null,
|
|
299
|
+
} } : {}),
|
|
300
|
+
};
|
|
301
|
+
const keys = await resolveKeys(store, { localDir: null, actorId: site.actor, log: this.log });
|
|
302
|
+
const agent = { urls: site, store, config, remote: this.remote, log: this.log, configured: () => true };
|
|
303
|
+
agent.deliverer = new Deliverer({
|
|
304
|
+
store, rsaPrivate: keys.rsaPrivate, keyId: site.actor + '#main-key', actorId: site.actor,
|
|
305
|
+
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true,
|
|
306
|
+
});
|
|
307
|
+
agent.publisher = new Publisher({
|
|
308
|
+
config, remote: this.remote, store, deliverer: agent.deliverer,
|
|
309
|
+
publicKeyPem: keys.rsaPublicPem, assertionKey: keys.edPublicMultibase, log: this.log,
|
|
310
|
+
resolveActor: (u) => this.intake.fetchAP(u), probeFetch: this.probeFetch || null,
|
|
311
|
+
});
|
|
312
|
+
this.intake = new ForumIntake({
|
|
313
|
+
config, urls: site, remote: this.remote, store, deliverer: agent.deliverer, publisher: agent.publisher,
|
|
314
|
+
log: this.log, lease: this.lease, push: this.push, pollSeconds: this.pollSeconds,
|
|
315
|
+
archive: this.storageFor(site.home + 'inbox-archive/', (u, i) => this.remote.fetch(u, i)),
|
|
316
|
+
ownerPost: () => {},
|
|
317
|
+
}, this);
|
|
318
|
+
agent.intake = this.intake;
|
|
319
|
+
return agent;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Which categories an activity is for: the ones it names, in its
|
|
323
|
+
// addressing or its object's, or whose topic it replies into. Pure
|
|
324
|
+
// addressing; the category's own intake does the verifying.
|
|
325
|
+
route(activity) {
|
|
326
|
+
const ids = new Set();
|
|
327
|
+
const add = (v) => { const id = idOf(v); if (id) ids.add(id); };
|
|
328
|
+
const obj = activity?.object && typeof activity.object === 'object' ? activity.object : null;
|
|
329
|
+
for (const f of ['to', 'cc', 'audience']) {
|
|
330
|
+
arr(activity?.[f]).forEach(add);
|
|
331
|
+
if (obj) arr(obj[f]).forEach(add);
|
|
332
|
+
}
|
|
333
|
+
add(activity?.object); add(activity?.target); add(activity?.origin);
|
|
334
|
+
if (obj) { add(obj.context); add(obj.inReplyTo); add(obj.target); add(obj.object); }
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const cat of this.categories) {
|
|
337
|
+
const u = cat.urls;
|
|
338
|
+
const mine = new Set([u.actor, u.followers, u.moderators, u.featured, u.topics, u.outbox]);
|
|
339
|
+
let hit = [...ids].some(id => mine.has(id) || id.startsWith(u.topicContainer) || id.startsWith(u.notes));
|
|
340
|
+
if (!hit && obj?.inReplyTo) hit = !!topics.topicOf(cat.store, idOf(obj.inReplyTo));
|
|
341
|
+
if (hit) out.push(cat);
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
namesSite(activity) {
|
|
347
|
+
const s = this.site;
|
|
348
|
+
const ids = [activity?.object, activity?.target, ...arr(activity?.to), ...arr(activity?.cc)].map(idOf).filter(Boolean);
|
|
349
|
+
return ids.some(id => id === s.actor || id === s.followers);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// After a category carried a post: place it in its topic, keep a copy for
|
|
353
|
+
// readers, and publish what changed.
|
|
354
|
+
async onCarried(cat, { noteId, sent }) {
|
|
355
|
+
let note = cat.intake.recentNotes.get(noteId) || null;
|
|
356
|
+
cat.intake.recentNotes.delete(noteId);
|
|
357
|
+
if (!note) note = await cat.intake.fetchAP(noteId);
|
|
358
|
+
if (!note) { this.log(`carried ${noteId} but could not read it for the topic`); return; }
|
|
359
|
+
const tid = await topics.assign({ store: cat.store, urls: cat.urls, fetchAP: (u) => cat.intake.fetchAP(u) }, note, sent);
|
|
360
|
+
// A locked topic takes no more: the post leaves it again and the carry is
|
|
361
|
+
// unsaid, so members' servers do not keep what the forum does not.
|
|
362
|
+
if (moderation.isLocked(cat, tid) && !topics.list(cat.store).find(t => t.tid === tid && t.op === noteId)) {
|
|
363
|
+
topics.remove(cat.store, tid, noteId);
|
|
364
|
+
await cat.intake.retract(noteId).catch(e => this.log(`retract ${noteId}: ${e.message}`));
|
|
365
|
+
this.log(`${cat.slug}: ${noteId} not placed — topic ${tid} is locked`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
await publish.cachePost(cat, note, { topic: cat.urls.topic(tid), replies: 0 });
|
|
369
|
+
this.noteLatest(cat, note);
|
|
370
|
+
const answered = idOf(note.inReplyTo);
|
|
371
|
+
if (answered) await this.countReplies(cat, tid, answered).catch(e => this.log(`replies of ${answered}: ${e.message}`));
|
|
372
|
+
// The author's card, from the actor the intake already holds; fetched
|
|
373
|
+
// once when it does not, so the website can name them.
|
|
374
|
+
const author = idOf([].concat(note.attributedTo || [])[0]);
|
|
375
|
+
if (author) {
|
|
376
|
+
const held = cat.store.getActors?.()[author];
|
|
377
|
+
const doc = held ? { id: author, ...held } : await cat.intake.fetchAP(author).catch(() => null);
|
|
378
|
+
if (doc) await publish.cacheAuthor(cat, doc).catch(e => this.log(`author card for ${author}: ${e.message}`));
|
|
379
|
+
}
|
|
380
|
+
await publish.publishTopic(cat, tid);
|
|
381
|
+
await publish.publishTopicIndex(cat);
|
|
382
|
+
await publish.publishLatest(this.siteAgent);
|
|
383
|
+
this.log(`${cat.slug}: ${noteId} in topic ${tid}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Take addresses at a Gateway: one row for the forum and one per category,
|
|
387
|
+
// each a handle at the front, all writing their mail into the forum's one
|
|
388
|
+
// inbox. The pod session proves the pod; no password leaves this machine.
|
|
389
|
+
// The secrets come back once and are kept in the forum's config; every
|
|
390
|
+
// actor is republished with its front ids on the next start.
|
|
391
|
+
async attach({ front, attachOne = null }) {
|
|
392
|
+
const origin = String(front).replace(/\/$/u, '');
|
|
393
|
+
const cred = this.readCredential();
|
|
394
|
+
if (!cred || !this.store || !this.config) throw new Error('connect first');
|
|
395
|
+
const plain = forumUrls(cred.remotePod, cred.root || ROOT);
|
|
396
|
+
const post = attachOne || (async (body) => {
|
|
397
|
+
const res = await this.remote.session.fetch(`${origin}/api/attach`, {
|
|
398
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
399
|
+
});
|
|
400
|
+
const d = await res.json().catch(() => ({}));
|
|
401
|
+
if (res.status !== 201 || !d.hmacSecret) throw new Error(`attach ${body.handle} at ${origin}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
|
|
402
|
+
return d;
|
|
403
|
+
});
|
|
404
|
+
const secrets = {};
|
|
405
|
+
const rows = [
|
|
406
|
+
{ handle: this.config.handle, podHome: plain.home, actorUrl: plain.actor, kind: 'application' },
|
|
407
|
+
...(this.config.categories || []).map(c => ({ handle: c.slug, podHome: plain.category(c.slug).home,
|
|
408
|
+
actorUrl: plain.category(c.slug).actor, kind: 'group' })),
|
|
409
|
+
];
|
|
410
|
+
for (const row of rows) {
|
|
411
|
+
const d = await post({ ...row, fronted: true, inboxUrl: plain.inbox });
|
|
412
|
+
secrets[row.handle] = String(d.hmacSecret);
|
|
413
|
+
this.log(`attached @${row.handle}@${new URL(origin).host}`);
|
|
414
|
+
}
|
|
415
|
+
this.store.setConfig({ ...this.store.getConfig(), gateway: { front: origin, mode: 'trust', secrets }, republish: true });
|
|
416
|
+
await this.store.flush();
|
|
417
|
+
return { front: origin, handles: rows.map(r => r.handle) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// One category's handle at the Gateway, claimed when a moderator creates
|
|
421
|
+
// that category from the settings page and at no other time. A name on a
|
|
422
|
+
// Gateway is public and only its admin can take one back, so nothing here
|
|
423
|
+
// claims names on its own account — not at a start, not on a repair, not
|
|
424
|
+
// for a category that arrived any other way.
|
|
425
|
+
async claimHandle(slug) {
|
|
426
|
+
const gw = this.config.gateway;
|
|
427
|
+
if (!gw?.front) return null; // not fronted: nothing to claim
|
|
428
|
+
if (gw.secrets?.[slug]) return null; // already has one
|
|
429
|
+
const cred = this.readCredential();
|
|
430
|
+
const plain = forumUrls(cred.remotePod, cred.root || ROOT);
|
|
431
|
+
const urls = plain.category(slug);
|
|
432
|
+
const origin = String(gw.front).replace(/\/$/u, '');
|
|
433
|
+
const res = await this.remote.session.fetch(`${origin}/api/attach`, {
|
|
434
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({ handle: slug, podHome: urls.home, actorUrl: urls.actor, kind: 'group',
|
|
436
|
+
fronted: true, inboxUrl: plain.inbox }),
|
|
437
|
+
});
|
|
438
|
+
const d = await res.json().catch(() => ({}));
|
|
439
|
+
if (res.status !== 201 || !d.hmacSecret) {
|
|
440
|
+
// A name already taken, or a Gateway that refuses: the category is
|
|
441
|
+
// hosted all the same, with no handle until someone sorts it out.
|
|
442
|
+
this.log(`no handle for @${slug}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
const cfg = this.store.getConfig();
|
|
446
|
+
this.store.setConfig({ ...cfg, gateway: { ...cfg.gateway, secrets: { ...(cfg.gateway?.secrets || {}), [slug]: String(d.hmacSecret) } }, republish: true });
|
|
447
|
+
this.config = this.store.getConfig();
|
|
448
|
+
await this.store.flush().catch(() => {});
|
|
449
|
+
this.log(`claimed @${slug}@${new URL(origin).host}`);
|
|
450
|
+
return slug;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// A queued moderator's ask, applied by the operator.
|
|
454
|
+
// Topics written into a sub-folder before they were flat: read each one at
|
|
455
|
+
// its old address and put it where the state can see it.
|
|
456
|
+
async carryOldTopics(cat) {
|
|
457
|
+
for (const t of topics.list(cat.store)) {
|
|
458
|
+
if (topics.get(cat.store, t.tid)) continue;
|
|
459
|
+
const old = await this.remote.getJson(cat.urls.state + topics.topicDocOld(t.tid)).catch(() => null);
|
|
460
|
+
if (!old) continue;
|
|
461
|
+
cat.store.write(topics.topicDoc(t.tid), old);
|
|
462
|
+
this.log(`topic ${t.tid} carried into the state`);
|
|
463
|
+
}
|
|
464
|
+
await cat.store.flush().catch(() => {});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// An author edited a post of theirs that we hold: the copy the website
|
|
468
|
+
// reads is rewritten from the note as verified at its origin, and a changed
|
|
469
|
+
// title is the topic's title when that post opened it.
|
|
470
|
+
async onCarriedEdit(cat, { noteId, note }) {
|
|
471
|
+
// The copy is rewritten from the note as verified at its origin; the
|
|
472
|
+
// counts the forum keeps on it are put back from what the forum knows.
|
|
473
|
+
const v = cat.store.read('votes.json', {})[noteId];
|
|
474
|
+
const held = topics.topicOf(cat.store, noteId);
|
|
475
|
+
const replies = held ? (topics.get(cat.store, held)?.posts || []).filter(p => p.inReplyTo === noteId).length : null;
|
|
476
|
+
await publish.cachePost(cat, note, { topic: held ? cat.urls.topic(held) : null, replies,
|
|
477
|
+
likes: (Array.isArray(v) ? v : v?.up || []).length, dislikes: (Array.isArray(v) ? [] : v?.down || []).length });
|
|
478
|
+
await publish.publishLatest(this.siteAgent);
|
|
479
|
+
const tid = topics.topicOf(cat.store, noteId);
|
|
480
|
+
if (!tid) return;
|
|
481
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// An author deleted one: it leaves the topic, its copy becomes a tombstone
|
|
485
|
+
// (FEP-4f05), and a topic with nothing left in it goes too. The carry has
|
|
486
|
+
// already been taken back by the group itself.
|
|
487
|
+
async onCarriedGone(cat, { noteId }) {
|
|
488
|
+
const tid = topics.topicOf(cat.store, noteId);
|
|
489
|
+
// Whatever it answered has one fewer answer now.
|
|
490
|
+
const answered = tid ? idOf(topics.get(cat.store, tid)?.posts?.find(p => p.id === noteId)?.inReplyTo) : null;
|
|
491
|
+
this.dropLatest(cat, noteId);
|
|
492
|
+
await publish.tombstoneCached(cat, noteId);
|
|
493
|
+
await publish.publishLatest(this.siteAgent);
|
|
494
|
+
if (!tid) return;
|
|
495
|
+
topics.remove(cat.store, tid, noteId);
|
|
496
|
+
const left = topics.get(cat.store, tid);
|
|
497
|
+
if (!left?.posts?.length) {
|
|
498
|
+
await moderation.deleteTopic(cat, tid).catch(e => this.log(`empty topic ${tid}: ${e.message}`));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
502
|
+
await publish.publishTopicIndex(cat, { force: true });
|
|
503
|
+
if (answered) await this.countReplies(cat, tid, answered).catch(e => this.log(`replies of ${answered}: ${e.message}`));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Queued asks from listed moderators that can be verified at their own
|
|
507
|
+
// origin are applied; everything else waits for a person.
|
|
508
|
+
async applyVerifiedAsks() {
|
|
509
|
+
await this.applySettingsAsks().catch(e => this.log(`settings: ${e.message}`));
|
|
510
|
+
for (const cat of this.categories) {
|
|
511
|
+
const q = cat.store.read('modqueue.json', []);
|
|
512
|
+
for (const entry of [...q]) {
|
|
513
|
+
if (!(this.config.moderators || []).includes(entry.moderator)) continue;
|
|
514
|
+
const id = entry.activity?.id;
|
|
515
|
+
if (typeof id !== 'string') continue;
|
|
516
|
+
if (!cat.intake.sameIdentity(id, entry.moderator)) continue;
|
|
517
|
+
const doc = await cat.intake.fetchAP(id).catch(() => null);
|
|
518
|
+
if (!doc || doc.type !== entry.type) continue;
|
|
519
|
+
if (idOf(doc.actor) !== entry.moderator) continue;
|
|
520
|
+
try {
|
|
521
|
+
// Apply the copy fetched at its origin, not the one delivered: the
|
|
522
|
+
// queue keeps a trimmed activity — enough to say what was asked for,
|
|
523
|
+
// not enough to carry a new name — and the origin's copy is the one
|
|
524
|
+
// its author actually published.
|
|
525
|
+
const q2 = cat.store.read('modqueue.json', []);
|
|
526
|
+
const i = q2.findIndex(x => x.id === entry.id);
|
|
527
|
+
if (i >= 0) { q2[i] = { ...q2[i], activity: doc, verified: true }; cat.store.write('modqueue.json', q2); }
|
|
528
|
+
await this.applyModeration(cat.slug, entry.id);
|
|
529
|
+
this.log(`applied ${entry.type} from ${entry.moderator}`);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
this.log(`ask ${entry.id}: ${e.message}`);
|
|
532
|
+
// It stays in the queue and carries why it did not take, so a
|
|
533
|
+
// moderator sees an ask that failed rather than one that silently
|
|
534
|
+
// did nothing. The next sweep tries it again.
|
|
535
|
+
const q3 = cat.store.read('modqueue.json', []);
|
|
536
|
+
const j = q3.findIndex(x => x.id === entry.id);
|
|
537
|
+
if (j >= 0) {
|
|
538
|
+
q3[j] = { ...q3[j], failed: e.message, failedAt: new Date().toISOString() };
|
|
539
|
+
cat.store.write('modqueue.json', q3);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
membersOf(cat) { return access.membersOf(this, cat); }
|
|
547
|
+
readersOf(cat) { return access.readersOf(this, cat); }
|
|
548
|
+
podFetchAP(url) { return access.podFetchAP(this, url); }
|
|
549
|
+
dropUnreadableFollowers(cat) { return access.dropUnreadableFollowers(this, cat); }
|
|
550
|
+
|
|
551
|
+
// A pin that holds across the whole forum, not just one category: the site
|
|
552
|
+
// actor has a featured collection of its own, and this is it.
|
|
553
|
+
async sitePin(topicId, on) {
|
|
554
|
+
const { orderedCollection } = await import('fedipod/core/wire.mjs');
|
|
555
|
+
const collection = await import('fedipod/pod/collection.mjs');
|
|
556
|
+
const held = this.store.read('sitepins.json', []).filter(id => id !== topicId);
|
|
557
|
+
const ids = on ? [topicId, ...held] : held;
|
|
558
|
+
this.store.write('sitepins.json', ids);
|
|
559
|
+
await collection.writeFlat(this.remote, this.site.featured,
|
|
560
|
+
orderedCollection(this.site.featured, ids), { publicRead: true });
|
|
561
|
+
return { pinned: ids };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// A post from someone who is not a member. 'members' drops it, which is
|
|
565
|
+
// what a group does; 'review' holds it for a moderator, which is what a
|
|
566
|
+
// forum usually wants; 'open' is not offered — a forum that carries
|
|
567
|
+
// anything addressed to it is a forum for spam.
|
|
568
|
+
async onStranger(cat, { noteId, actor, activity }) {
|
|
569
|
+
const how = this.config.replyPolicy || 'open';
|
|
570
|
+
// A private category's membership IS the right to read it, and only a
|
|
571
|
+
// moderator gives that; a forum set to review holds a stranger either way.
|
|
572
|
+
const closed = (this.config.membersOnly || []).includes(cat.slug);
|
|
573
|
+
const hold = () => {
|
|
574
|
+
cat.intake.queueModeration(
|
|
575
|
+
{ type: 'Create', actor, object: noteId, ...(activity?.id ? { id: activity.id } : {}) },
|
|
576
|
+
actor, { trusted: false },
|
|
577
|
+
);
|
|
578
|
+
return true;
|
|
579
|
+
};
|
|
580
|
+
if (how === 'review' || closed) return hold();
|
|
581
|
+
return (await this.joinOnPost(cat, actor)) ? false : hold();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Posting into an open category joins it, which is what the website's own
|
|
585
|
+
// posting does before it posts: a post from elsewhere arrives with no Follow
|
|
586
|
+
// in front of it, and holding it for a moderator made every first post from
|
|
587
|
+
// another server wait. There is nothing to Accept — no Follow was sent — so
|
|
588
|
+
// the member is recorded and the category is published again.
|
|
589
|
+
async joinOnPost(cat, actor) {
|
|
590
|
+
try {
|
|
591
|
+
const contacts = cat.store.getContacts();
|
|
592
|
+
if (contacts.followers.some(f => f.actor === actor)) return true;
|
|
593
|
+
const doc = await cat.intake.fetchAP(actor);
|
|
594
|
+
if (!doc?.inbox) { this.log(`${actor} posted but its actor names no inbox — held instead`); return false; }
|
|
595
|
+
contacts.followers.push({ actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox });
|
|
596
|
+
cat.store.setContacts(contacts);
|
|
597
|
+
cat.store.addNotification({ type: 'follow', actor });
|
|
598
|
+
await cat.intake.republish({ followers: true });
|
|
599
|
+
this.log(`joined by posting: ${actor} → @${cat.slug}`);
|
|
600
|
+
return true;
|
|
601
|
+
} catch (e) {
|
|
602
|
+
this.log(`could not join ${actor} on posting (${e.message}) — held instead`);
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Requests about the forum itself: a category created, a name changed, who
|
|
608
|
+
// moderates, who may read. Checked at the asker's own pod exactly as a
|
|
609
|
+
// moderator's other asks are, then applied.
|
|
610
|
+
async applySettingsAsks() {
|
|
611
|
+
const q = this.store.read('modqueue.json', []);
|
|
612
|
+
for (const entry of [...q]) {
|
|
613
|
+
if (!(this.config.moderators || []).includes(entry.moderator)) continue;
|
|
614
|
+
const id = entry.activity?.id;
|
|
615
|
+
if (typeof id !== 'string' || !this.intake.sameIdentity(id, entry.moderator)) continue;
|
|
616
|
+
const doc = await this.intake.fetchAP(id).catch(() => null);
|
|
617
|
+
if (!doc || doc.type !== entry.type || idOf(doc.actor) !== entry.moderator) continue;
|
|
618
|
+
try {
|
|
619
|
+
const done = await settings.applySettings(this, doc);
|
|
620
|
+
// The one place a name is claimed: a moderator asked for this
|
|
621
|
+
// category by name a moment ago.
|
|
622
|
+
if (done.category && doc.type === 'Create') {
|
|
623
|
+
await this.claimHandle(done.category).catch(e => this.log(`handle for ${done.category}: ${e.message}`));
|
|
624
|
+
}
|
|
625
|
+
this.store.write('modqueue.json', this.store.read('modqueue.json', []).filter(e => e.id !== entry.id));
|
|
626
|
+
await this.store.flush().catch(() => {});
|
|
627
|
+
await this.noteModLog({ ...entry, activity: doc }, done);
|
|
628
|
+
this.log(`applied ${entry.type} from ${entry.moderator}: ${JSON.stringify(done)}`);
|
|
629
|
+
// A forum that has just changed shape publishes itself again.
|
|
630
|
+
if (Object.keys(done).length) await this.reshape();
|
|
631
|
+
} catch (e) { this.log(`settings ask ${entry.id}: ${e.message}`); }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// What a change of shape needs: any category named in the config that is
|
|
636
|
+
// not being hosted yet is built, and everything is published again.
|
|
637
|
+
async reshape() {
|
|
638
|
+
for (const c of this.config.categories || []) {
|
|
639
|
+
if (this.categories.some(x => x.slug === c.slug)) continue;
|
|
640
|
+
const cat = await this.buildCategory(c);
|
|
641
|
+
this.categories.push(cat);
|
|
642
|
+
this.log(`category added: ${c.slug}`);
|
|
643
|
+
}
|
|
644
|
+
if (this.site.toPod && this.remote.setUrlMap) this.remote.setUrlMap((u) => this.toPod(u));
|
|
645
|
+
await this.publishAll().catch(e => this.log(`after a change of shape: ${e.message}`));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// The WebID behind a Fediverse actor, when its pod says so. A FediPod
|
|
649
|
+
// account's WebID lists the actor as an account of the person; that link,
|
|
650
|
+
// read at the pod, is what makes it safe to grant them anything.
|
|
651
|
+
async webIdOf(actor) {
|
|
652
|
+
const doc = await this.intake.fetchAP(actor).catch(() => null);
|
|
653
|
+
const said = [doc?.webId, ...[].concat(doc?.alsoKnownAs || [])].find(v => typeof v === 'string' && v.includes('#'));
|
|
654
|
+
if (said) return said;
|
|
655
|
+
// Otherwise the pod the actor is published on, whose card is where a
|
|
656
|
+
// FediPod account records itself.
|
|
657
|
+
const home = new URL(actor).origin;
|
|
658
|
+
for (const card of [`${home}/profile/card`, `${home}/profile/card#me`]) {
|
|
659
|
+
const r = await fetch(card.replace(/#me$/u, ''), { headers: { accept: 'text/turtle' } }).catch(() => null);
|
|
660
|
+
if (!r?.ok) continue;
|
|
661
|
+
const text = await r.text().catch(() => '');
|
|
662
|
+
if (text.includes(actor)) return `${home}/profile/card#me`;
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// The moderators' queue, written where only they can read it: what each
|
|
668
|
+
// category is holding, and who asked for what.
|
|
669
|
+
// What was done, and by whom: a record for the moderators, beside their
|
|
670
|
+
// queue and under the same rule.
|
|
671
|
+
async noteModLog(entry, outcome) {
|
|
672
|
+
const log = this.store.read('modlog.json', []);
|
|
673
|
+
log.unshift({
|
|
674
|
+
at: new Date().toISOString(), type: entry.type, by: entry.moderator,
|
|
675
|
+
object: typeof entry.activity?.object === 'string' ? entry.activity.object : entry.activity?.object?.id || null,
|
|
676
|
+
outcome: outcome && typeof outcome === 'object' ? Object.keys(outcome).join(',') : String(outcome ?? ''),
|
|
677
|
+
});
|
|
678
|
+
this.store.write('modlog.json', log.slice(0, 500));
|
|
679
|
+
await this.remote.putJson(this.site.mod + 'log.json', { at: new Date().toISOString(), rows: log.slice(0, 500) }, 'application/json')
|
|
680
|
+
.catch(e => this.log(`mod log: ${e.message}`));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async publishModQueue() {
|
|
684
|
+
// The rule follows the configuration: moderators come and go, and the
|
|
685
|
+
// container was provisioned once, long before this one was named. Stated
|
|
686
|
+
// when the moderators change, and read rather than rewritten at start.
|
|
687
|
+
const mods = publish.digestOf(this.config.moderatorWebIds || []);
|
|
688
|
+
if (this._modRule !== mods) {
|
|
689
|
+
await this.remote.setAcl(this.site.mod, [], { readAgents: this.config.moderatorWebIds || [], ifChanged: true })
|
|
690
|
+
.catch(e => this.log(`queue rule: ${e.message}`));
|
|
691
|
+
this._modRule = mods;
|
|
692
|
+
}
|
|
693
|
+
const rows = [];
|
|
694
|
+
for (const cat of this.categories) {
|
|
695
|
+
for (const e of cat.store.read('modqueue.json', [])) {
|
|
696
|
+
const object = typeof e.activity?.object === 'string' ? e.activity.object : e.activity?.object?.id || null;
|
|
697
|
+
// Who wrote the thing being complained about, so a moderator acting
|
|
698
|
+
// on a report acts on its author and not on whoever reported it.
|
|
699
|
+
const about = object ? (cat.store.getStatuses().find(st => st.noteId === object)?.actor || null) : null;
|
|
700
|
+
rows.push({ category: cat.slug, id: e.id, type: e.type, by: e.moderator, at: e.at,
|
|
701
|
+
object, about, why: e.activity?.content || null, verified: !!e.verified,
|
|
702
|
+
...(e.failed ? { failed: e.failed, failedAt: e.failedAt || null } : {}) });
|
|
703
|
+
}
|
|
704
|
+
for (const r of cat.store.getRequests?.() || []) {
|
|
705
|
+
rows.push({ category: cat.slug, id: 'join:' + r.actor, type: 'Join request', by: r.actor, at: r.at || null, object: r.actor });
|
|
706
|
+
}
|
|
707
|
+
for (const p of cat.store.getPending?.() || []) {
|
|
708
|
+
rows.push({ category: cat.slug, id: 'pending:' + p.noteId, type: 'Held', by: p.actor || null, at: p.at || null, object: p.noteId });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
712
|
+
// Swept every minute, and on a quiet forum every sweep found the same
|
|
713
|
+
// rows: the timestamp alone made each one a write. The rows decide.
|
|
714
|
+
const digest = publish.digestOf(rows);
|
|
715
|
+
if (this._modQueue === digest) return rows.length;
|
|
716
|
+
await this.remote.putJson(this.site.mod + 'queue.json',
|
|
717
|
+
{ at: new Date().toISOString(), rows }, 'application/json');
|
|
718
|
+
this._modQueue = digest;
|
|
719
|
+
return rows.length;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// One vote per person per post, either way. Up is AS2's `likes` on the
|
|
723
|
+
// post's copy, which the website reads with the post. Down has no property
|
|
724
|
+
// in AS2 and none is invented for it: it is published as a collection of
|
|
725
|
+
// its own beside the copy, named for the post it counts.
|
|
726
|
+
//
|
|
727
|
+
// A record written before there were downvotes is a bare list of who voted
|
|
728
|
+
// for the post; it is read as the up list and written back in both parts.
|
|
729
|
+
async countVote({ post, actor, way }) {
|
|
730
|
+
if (!post || !actor) return false;
|
|
731
|
+
const cat = this.categories.find(c => !!topics.topicOf(c.store, post));
|
|
732
|
+
if (!cat) return false;
|
|
733
|
+
const votes = cat.store.read('votes.json', {});
|
|
734
|
+
const was = votes[post];
|
|
735
|
+
const up = new Set(Array.isArray(was) ? was : (was?.up || []));
|
|
736
|
+
const down = new Set(Array.isArray(was) ? [] : (was?.down || []));
|
|
737
|
+
// Changing your mind is not two votes: whichever way it goes now, the
|
|
738
|
+
// other way lets go of you.
|
|
739
|
+
up.delete(actor);
|
|
740
|
+
down.delete(actor);
|
|
741
|
+
if (way === 'up') up.add(actor);
|
|
742
|
+
if (way === 'down') down.add(actor);
|
|
743
|
+
votes[post] = { up: [...up], down: [...down] };
|
|
744
|
+
cat.store.write('votes.json', votes);
|
|
745
|
+
const copy = await this.remote.getJson(cat.urls.cached(post)).catch(() => null);
|
|
746
|
+
if (copy && copy.type !== 'Tombstone') {
|
|
747
|
+
await publish.cachePost(cat, copy, { likes: up.size, dislikes: down.size });
|
|
748
|
+
await publish.publishDislikes(cat, post, down.size).catch(e => this.log(`downvotes on ${post}: ${e.message}`));
|
|
749
|
+
await publish.publishLatest(this.siteAgent).catch(() => {});
|
|
750
|
+
}
|
|
751
|
+
this.log(`${way === 'none' ? 'vote withdrawn' : `vote ${way}`} on ${post} — ${up.size} up, ${down.size} down`);
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Answers to one post, counted in the topic that holds it, and written
|
|
756
|
+
// into the copy the website reads.
|
|
757
|
+
async countReplies(cat, tid, postId) {
|
|
758
|
+
const doc = topics.get(cat.store, tid);
|
|
759
|
+
if (!doc) return;
|
|
760
|
+
const n = (doc.posts || []).filter(p => p.inReplyTo === postId).length;
|
|
761
|
+
const copy = await this.remote.getJson(cat.urls.cached(postId)).catch(() => null);
|
|
762
|
+
if (!copy || copy.type === 'Tombstone') return;
|
|
763
|
+
if (Number(copy.replies?.totalItems) === n) return;
|
|
764
|
+
await publish.cachePost(cat, copy, { replies: n });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// The forum's own index of its newest posts, across every category.
|
|
768
|
+
noteLatest(cat, note) {
|
|
769
|
+
const copy = cat.urls.cached(note.id);
|
|
770
|
+
const at = note.published || new Date().toISOString();
|
|
771
|
+
const rows = this.store.read('latest.json', []).filter(e => e.copy !== copy);
|
|
772
|
+
rows.push({ copy, at });
|
|
773
|
+
rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
774
|
+
this.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Everything the forum already holds, read back out of the topics, for an
|
|
778
|
+
// index that did not exist when those posts arrived.
|
|
779
|
+
// Copies kept before the forum recorded which topic they were in: the
|
|
780
|
+
// topic knows, so the copy is rewritten from it.
|
|
781
|
+
async stampOldCopies(cat) {
|
|
782
|
+
for (const entry of topics.list(cat.store)) {
|
|
783
|
+
const doc = topics.get(cat.store, entry.tid);
|
|
784
|
+
for (const p of doc?.posts || []) {
|
|
785
|
+
const url = cat.urls.cached(p.id);
|
|
786
|
+
const copy = await this.remote.getJson(url).catch(() => null);
|
|
787
|
+
if (!copy || copy.type === 'Tombstone') continue;
|
|
788
|
+
const answers = (doc?.posts || []).filter(x => x.inReplyTo === p.id).length;
|
|
789
|
+
if (copy.context && Number(copy.replies?.totalItems) === answers) continue;
|
|
790
|
+
await publish.cachePost(cat, copy, { topic: cat.urls.topic(entry.tid), replies: answers });
|
|
791
|
+
this.log(`${p.id}: its topic and its answers recorded`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
rebuildLatest() {
|
|
797
|
+
const rows = [];
|
|
798
|
+
for (const cat of this.categories) {
|
|
799
|
+
for (const entry of topics.list(cat.store)) {
|
|
800
|
+
const doc = topics.get(cat.store, entry.tid);
|
|
801
|
+
for (const p of doc?.posts || []) rows.push({ copy: cat.urls.cached(p.id), at: p.published || entry.created || '' });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
805
|
+
this.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
|
|
806
|
+
return rows.length;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
dropLatest(cat, noteId) {
|
|
810
|
+
const copy = cat.urls.cached(noteId);
|
|
811
|
+
this.store.write('latest.json', this.store.read('latest.json', []).filter(e => e.copy !== copy));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async applyModeration(slug, entryId) {
|
|
815
|
+
const cat = this.categories.find(c => c.slug === slug);
|
|
816
|
+
if (!cat) throw new Error(`no such category: ${slug}`);
|
|
817
|
+
const q = cat.store.read('modqueue.json', []);
|
|
818
|
+
const entry = q.find(e => e.id === entryId);
|
|
819
|
+
if (!entry) throw new Error(`no such queue entry: ${entryId}`);
|
|
820
|
+
const r = await moderation.applyForumModeration(this, cat, entry);
|
|
821
|
+
cat.store.write('modqueue.json', cat.store.read('modqueue.json', []).filter(e => e.id !== entryId));
|
|
822
|
+
await this.noteModLog(entry, r);
|
|
823
|
+
await this.publishModQueue().catch(e => this.log(`queue: ${e.message}`));
|
|
824
|
+
return r;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Publish every actor whose document is not yet up, and the forum's lists.
|
|
828
|
+
async publishAll({ force = false } = {}) {
|
|
829
|
+
// Cleared at the END, not here: a publish that dies part-way used to lower
|
|
830
|
+
// the flag on its way in, so the next start thought the work was done and
|
|
831
|
+
// the forum kept the name and ids it was told to replace.
|
|
832
|
+
const asked = !!this.config.republish;
|
|
833
|
+
if (asked) force = true;
|
|
834
|
+
// Containers and their rules are rewritten only when who may read what
|
|
835
|
+
// has changed. A republish otherwise leaves them alone: rewriting dozens
|
|
836
|
+
// of rules on every attempt is what a rate-limited pod refuses, and a
|
|
837
|
+
// refusal used to mean the request was never cleared and never finished.
|
|
838
|
+
const redo = !!this.config.reprovision;
|
|
839
|
+
// Containers and their access rules are written once, not on every start:
|
|
840
|
+
// a forum that re-wrote them each time it came up spent dozens of pod
|
|
841
|
+
// writes saying what the pod already said, and a busy pod answered 429.
|
|
842
|
+
const made = this.store.read('provisioned.json', {});
|
|
843
|
+
if (redo || !made.forum) {
|
|
844
|
+
await provisionForum(this.remote, this.site, { moderatorWebIds: this.config.moderatorWebIds || [] });
|
|
845
|
+
this.store.write('provisioned.json', { ...made, forum: new Date().toISOString() });
|
|
846
|
+
}
|
|
847
|
+
for (const cat of this.categories) {
|
|
848
|
+
const done = this.store.read('provisioned.json', {});
|
|
849
|
+
if (redo || !done[cat.slug]) {
|
|
850
|
+
await provisionCategory(this.remote, cat.urls, { memberWebIds: this.membersOf(cat) });
|
|
851
|
+
this.store.write('provisioned.json', { ...done, [cat.slug]: new Date().toISOString() });
|
|
852
|
+
}
|
|
853
|
+
// Who the category's posts are written for, and letting go of anyone it
|
|
854
|
+
// cannot let read. Both are cheap when nothing changed: the list is
|
|
855
|
+
// written only when its digest moved, and the sweep only for a private
|
|
856
|
+
// category.
|
|
857
|
+
await publish.publishMembers(cat, this.readersOf(cat), { force: redo })
|
|
858
|
+
.catch(e => this.log(`the reader list for ${cat.slug}: ${e.message}`));
|
|
859
|
+
await this.dropUnreadableFollowers(cat)
|
|
860
|
+
.catch(e => this.log(`letting go of ${cat.slug}'s strangers: ${e.message}`));
|
|
861
|
+
await this.carryOldTopics(cat);
|
|
862
|
+
await this.stampOldCopies(cat).catch(e => this.log(`stamping ${cat.slug}: ${e.message}`));
|
|
863
|
+
const seen = cat.store.read('published.json', {});
|
|
864
|
+
if (force || !seen.actorDigest) {
|
|
865
|
+
await cat.publisher.publishProfile({ force });
|
|
866
|
+
// The category's own inbox container exists because the group's
|
|
867
|
+
// publisher made it; nothing drains it, so nobody may append to it.
|
|
868
|
+
await podInbox.setPosture(this.remote, cat.urls, 'closed');
|
|
869
|
+
// The profile publish above wrote the category's featured list as its
|
|
870
|
+
// pinned posts, of which a forum has none: the pinned topics go back.
|
|
871
|
+
await publish.publishPinnedTopics(cat);
|
|
872
|
+
await publish.publishTopicIndex(cat, { force: true });
|
|
873
|
+
for (const t of topics.list(cat.store)) {
|
|
874
|
+
// A topic the state cannot produce must not stop the forum coming
|
|
875
|
+
// up: say so and publish the rest.
|
|
876
|
+
if (!topics.get(cat.store, t.tid)) { this.log(`topic ${t.tid} has no record — skipped`); continue; }
|
|
877
|
+
await publish.publishTopic(cat, t.tid, { force: true });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// A reprovision states every rule again, the forum's flat lists' among
|
|
882
|
+
// them. Their rule is written with their first publish, so the record of
|
|
883
|
+
// that publish is forgotten here and the publish below writes it.
|
|
884
|
+
if (redo) this.store.write('published.json', { ...this.store.read('published.json', {}), categories: null, latest: null, administrators: null });
|
|
885
|
+
const seen = this.store.read('published.json', {});
|
|
886
|
+
if (force || !seen.actorDigest) await this.siteAgent.publisher.publishProfile({ force });
|
|
887
|
+
await publish.publishCategories(this.siteAgent, this.categories.map(c => c.urls.actor), { force });
|
|
888
|
+
if (!this.store.read('latest.json', []).length) {
|
|
889
|
+
const n = this.rebuildLatest();
|
|
890
|
+
if (n) this.log(`latest: ${n} post(s) read back out of the topics`);
|
|
891
|
+
}
|
|
892
|
+
await publish.publishLatest(this.siteAgent, { force });
|
|
893
|
+
await publish.publishAdministrators(this.siteAgent, this.config.moderators || [], { force });
|
|
894
|
+
if (asked) {
|
|
895
|
+
this.store.setConfig({ ...this.store.getConfig(), republish: false, reprovision: false });
|
|
896
|
+
this.config = this.store.getConfig();
|
|
897
|
+
await this.store.flush().catch(() => {});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async startActive() {
|
|
902
|
+
this.viewer = false;
|
|
903
|
+
clearInterval(this.refreshTimer);
|
|
904
|
+
this.lease.onLost = () => this.demote();
|
|
905
|
+
this.lease.startRenewal();
|
|
906
|
+
for (const cat of this.categories) cat.deliverer.startQueue();
|
|
907
|
+
this.siteAgent.deliverer.startQueue();
|
|
908
|
+
await this.publishAll();
|
|
909
|
+
this.intake.afterDrain = () => this.applyVerifiedAsks()
|
|
910
|
+
.then(() => this.publishModQueue())
|
|
911
|
+
.catch(e => this.log(`asks: ${e.message}`));
|
|
912
|
+
await this.intake.start();
|
|
913
|
+
await publish.publishHeartbeat(this.siteAgent).catch(e => this.log(`heartbeat: ${e.message}`));
|
|
914
|
+
this.heartbeatTimer = setInterval(() => {
|
|
915
|
+
publish.publishHeartbeat(this.siteAgent).catch(e => this.log(`heartbeat: ${e.message}`));
|
|
916
|
+
}, HEARTBEAT_MS);
|
|
917
|
+
// An ask is checked at its asker's pod before it is acted on, and that
|
|
918
|
+
// check used to happen only when the inbox was drained. A moderator asking
|
|
919
|
+
// for something on a quiet forum therefore waited for the next person to
|
|
920
|
+
// post — and if the document was a moment from being readable when the ask
|
|
921
|
+
// arrived, it waited for the one after that. So it is swept on its own
|
|
922
|
+
// clock as well.
|
|
923
|
+
this.asksTimer = setInterval(() => {
|
|
924
|
+
this.applyVerifiedAsks()
|
|
925
|
+
.then(() => this.publishModQueue())
|
|
926
|
+
.catch(e => this.log(`asks: ${e.message}`));
|
|
927
|
+
}, ASK_SWEEP_MS);
|
|
928
|
+
this.asksTimer.unref?.();
|
|
929
|
+
this.heartbeatTimer.unref?.();
|
|
930
|
+
this.log(`hosting ${this.config.handle}: ${this.categories.map(c => '@' + c.slug).join(', ')}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Watching: refresh what is held, and act the moment the lease frees.
|
|
934
|
+
startViewer() {
|
|
935
|
+
this.viewer = true;
|
|
936
|
+
this.refreshTimer = setInterval(() => this.tryPromote().catch(e => this.log(`viewer: ${e.message}`)),
|
|
937
|
+
Math.round(VIEWER_REFRESH_MS * (0.85 + Math.random() * 0.3)));
|
|
938
|
+
this.refreshTimer.unref?.();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async tryPromote() {
|
|
942
|
+
if (!this.viewer) return true;
|
|
943
|
+
if (await this.lease.acquire()) {
|
|
944
|
+
this.log('lease freed — this device now hosts the forum');
|
|
945
|
+
await this.store.load({ force: true }).catch(() => {});
|
|
946
|
+
for (const cat of this.categories) await cat.store.load({ force: true }).catch(() => {});
|
|
947
|
+
try {
|
|
948
|
+
await this.startActive();
|
|
949
|
+
} catch (e) {
|
|
950
|
+
// A promotion that dies part-way left the forum neither hosting nor
|
|
951
|
+
// watching: the timer was cleared on the way in and the lease was
|
|
952
|
+
// held by a device doing nothing. Give both back.
|
|
953
|
+
this.log(`could not start hosting (${e.message}) — watching again`);
|
|
954
|
+
for (let i = 0; i < 4; i++) {
|
|
955
|
+
try { await this.lease.release(); break; } catch { await new Promise(r => setTimeout(r, 8000)); }
|
|
956
|
+
}
|
|
957
|
+
this.startViewer();
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
await this.store.load().catch(() => {});
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
demote() {
|
|
967
|
+
if (this.viewer) return;
|
|
968
|
+
this.log('another device took the forum over — watching');
|
|
969
|
+
this.intake?.stop();
|
|
970
|
+
for (const cat of this.categories) cat.deliverer.stop();
|
|
971
|
+
this.siteAgent?.deliverer.stop();
|
|
972
|
+
clearInterval(this.heartbeatTimer);
|
|
973
|
+
clearInterval(this.asksTimer);
|
|
974
|
+
this.lease?.stopRenewal();
|
|
975
|
+
this.startViewer();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async stop() {
|
|
979
|
+
clearInterval(this.refreshTimer);
|
|
980
|
+
clearInterval(this.heartbeatTimer);
|
|
981
|
+
clearInterval(this.asksTimer);
|
|
982
|
+
this.intake?.stop();
|
|
983
|
+
for (const cat of this.categories) cat.deliverer.stop();
|
|
984
|
+
this.siteAgent?.deliverer.stop();
|
|
985
|
+
this.lease?.stopRenewal();
|
|
986
|
+
await Promise.allSettled([
|
|
987
|
+
this.store?.flush(), ...this.categories.map(c => c.store.flush()),
|
|
988
|
+
this.viewer ? Promise.resolve() : this.lease?.release(),
|
|
989
|
+
]);
|
|
990
|
+
}
|
|
991
|
+
}
|