fedipod-bb 0.2.0 → 0.4.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 +6 -0
- package/bin/fedipod-bb.mjs +15 -1
- package/package.json +9 -2
- package/site/bb.js +21 -14
- package/site/pod.mjs +6 -12
- package/src/forum-agent.mjs +89 -165
- package/src/forum-intake.mjs +24 -0
- package/src/gateway.mjs +215 -0
- package/src/index.mjs +1 -0
- package/src/keep.mjs +40 -0
- package/src/place.mjs +110 -0
- package/src/setup.mjs +163 -0
package/README.md
CHANGED
|
@@ -21,6 +21,12 @@ node --test packages/fedipod-bb/test/*.test.mjs
|
|
|
21
21
|
|
|
22
22
|
## Hosting a forum
|
|
23
23
|
|
|
24
|
+
A forum can be made at a FediPod Gateway's sign-up page (fedipod.net's, for
|
|
25
|
+
one) with no machine of your own: choose "A forum", make a pod for it at a
|
|
26
|
+
provider, name it and its categories, and the Gateway writes it on the pod
|
|
27
|
+
and runs it from then on. The commands below are for running a forum from a
|
|
28
|
+
machine of your own instead.
|
|
29
|
+
|
|
24
30
|
```
|
|
25
31
|
npm install -g fedipod-bb
|
|
26
32
|
fedipod-bb credential --home DIR --email you@example.org --pod https://forum.example/
|
package/bin/fedipod-bb.mjs
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
// per category. Deliveries arrive verified at the front and are written
|
|
21
21
|
// into the forum's inbox; every actor is republished with its front ids
|
|
22
22
|
// on the next start.
|
|
23
|
+
// fedipod-bb keep --home DIR --front https://fedipod.net
|
|
24
|
+
// Let that Gateway run the forum when nothing else does: the pod's rules
|
|
25
|
+
// name its keeper from here on, and it places posts as they land. `unkeep`
|
|
26
|
+
// takes the name out again.
|
|
23
27
|
|
|
24
28
|
import fs from 'node:fs';
|
|
25
29
|
import path from 'node:path';
|
|
@@ -102,12 +106,22 @@ if (cmd === 'credential') {
|
|
|
102
106
|
const r = await agent.attach({ front });
|
|
103
107
|
console.log(`attached at ${r.front}: ${r.handles.map(h => '@' + h).join(', ')} — start the forum to publish its new addresses`);
|
|
104
108
|
process.exit(0);
|
|
109
|
+
} else if (cmd === 'keep' || cmd === 'unkeep') {
|
|
110
|
+
const front = flag('front');
|
|
111
|
+
if (!front) { console.error('--front <https://gateway-origin> is required'); process.exit(2); }
|
|
112
|
+
const agent = new ForumAgent({ home, log });
|
|
113
|
+
if (!await agent.connect({ act: false })) { console.error('nothing to keep — run init first'); process.exit(1); }
|
|
114
|
+
const r = await agent.keep({ front, on: cmd === 'keep' });
|
|
115
|
+
console.log(cmd === 'keep'
|
|
116
|
+
? `${front} keeps this forum (${r.handles.map(h => '@' + h).join(', ')}) as ${r.keeper}`
|
|
117
|
+
: `${front} no longer keeps this forum`);
|
|
118
|
+
process.exit(0);
|
|
105
119
|
} else if (cmd === 'status') {
|
|
106
120
|
const agent = new ForumAgent({ home, log: () => {} });
|
|
107
121
|
const up = await agent.connect({ act: false });
|
|
108
122
|
console.log(JSON.stringify(up ? agent.status() : { mode: 'unconfigured' }, null, 2));
|
|
109
123
|
process.exit(0);
|
|
110
124
|
} else {
|
|
111
|
-
console.log('usage: fedipod-bb <credential|init|start|status|attach> --home DIR [--email E --pod URL | --handle H --name N --category slug:Name … --reply-policy open|review | --front URL]');
|
|
125
|
+
console.log('usage: fedipod-bb <credential|init|start|status|attach|keep|unkeep> --home DIR [--email E --pod URL | --handle H --name N --category slug:Name … --reply-policy open|review | --front URL]');
|
|
112
126
|
process.exit(2);
|
|
113
127
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod-bb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/index.mjs",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.mjs",
|
|
10
|
+
"./gateway": "./src/gateway.mjs",
|
|
11
|
+
"./setup": "./src/setup.mjs",
|
|
12
|
+
"./src/*": "./src/*",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
8
15
|
"bin": {
|
|
9
16
|
"fedipod-bb": "bin/fedipod-bb.mjs"
|
|
10
17
|
},
|
|
@@ -20,7 +27,7 @@
|
|
|
20
27
|
"test": "node --test test/*.test.mjs"
|
|
21
28
|
},
|
|
22
29
|
"dependencies": {
|
|
23
|
-
"fedipod": "^1.
|
|
30
|
+
"fedipod": "^1.44.0",
|
|
24
31
|
"fediverse-session": "^0.2.1"
|
|
25
32
|
}
|
|
26
33
|
}
|
package/site/bb.js
CHANGED
|
@@ -39,8 +39,14 @@ const place = placeOf({ origin: location.origin, pathname: location.pathname, se
|
|
|
39
39
|
const { front, base } = place;
|
|
40
40
|
const frontHost = new URL(front).host;
|
|
41
41
|
// The reader asks anonymously first; where a category is members-only it
|
|
42
|
-
// asks again with the pod session, if there is one.
|
|
43
|
-
|
|
42
|
+
// asks again with the pod session, if there is one. Right after this reader
|
|
43
|
+
// posts, the next read goes past the browser's own copy of each document (a
|
|
44
|
+
// front lets a browser keep one for a minute): a post the forum placed as it
|
|
45
|
+
// arrived is then on the page at once, not a minute later.
|
|
46
|
+
let freshRead = false;
|
|
47
|
+
const readerFetch = (url, init = {}) => fetch(url, freshRead ? { ...init, cache: 'reload' } : init);
|
|
48
|
+
const makeReader = (session = null) => reader({ session, fetch: readerFetch });
|
|
49
|
+
let read = makeReader();
|
|
44
50
|
const login = new MastoLogin({ storage: localStorage, redirectUri: location.origin + location.pathname + location.search });
|
|
45
51
|
// What this reader has already read. Theirs, in their own browser: a public
|
|
46
52
|
// forum has nowhere to keep it and no business keeping it.
|
|
@@ -989,7 +995,7 @@ async function report(id) {
|
|
|
989
995
|
if (why === null) return;
|
|
990
996
|
try {
|
|
991
997
|
const cat = replyCtx.cat;
|
|
992
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
998
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
993
999
|
const who = podAcct?.actor || login.account()?.url;
|
|
994
1000
|
await pod.report({ actor: who, object: id, category: cat.id, inbox, why });
|
|
995
1001
|
alert('Reported. A moderator will see it in the forum\'s queue.');
|
|
@@ -1007,7 +1013,7 @@ async function asksTo(activity, forCat = null) {
|
|
|
1007
1013
|
// The same reason voteOn says it.
|
|
1008
1014
|
let step = 'finding where the forum takes mail';
|
|
1009
1015
|
try {
|
|
1010
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1016
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1011
1017
|
if (!inbox) throw new Error('the forum did not say where to send it');
|
|
1012
1018
|
// moderate() names its own two requests, so nothing is added over the top.
|
|
1013
1019
|
step = 'sending the request';
|
|
@@ -1049,7 +1055,7 @@ async function joinOrLeave(categoryId, on) {
|
|
|
1049
1055
|
return;
|
|
1050
1056
|
}
|
|
1051
1057
|
try {
|
|
1052
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1058
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1053
1059
|
const ok = on
|
|
1054
1060
|
? await pod.join({ actor: podAcct.actor, category: cat.id, inbox })
|
|
1055
1061
|
: await pod.leave({ actor: podAcct.actor, category: cat.id, inbox });
|
|
@@ -1129,7 +1135,7 @@ async function voteOn(postId, way, was) {
|
|
|
1129
1135
|
if (!cat) return;
|
|
1130
1136
|
let step = 'finding where the forum takes mail';
|
|
1131
1137
|
try {
|
|
1132
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1138
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1133
1139
|
if (!inbox) throw new Error('the forum did not say where to send it');
|
|
1134
1140
|
step = `sending the vote to ${new URL(inbox).host}`;
|
|
1135
1141
|
await pod.vote({ actor: podAcct.actor, post: postId, category: cat.id, inbox, way, was });
|
|
@@ -1207,7 +1213,7 @@ async function askToJoin(categoryId) {
|
|
|
1207
1213
|
const cat = cats().find(c => c.id === categoryId);
|
|
1208
1214
|
if (!cat || !podAcct) return;
|
|
1209
1215
|
try {
|
|
1210
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1216
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1211
1217
|
await pod.join({ actor: podAcct.actor, category: cat.id, inbox });
|
|
1212
1218
|
say('Asked. A moderator sees it in the forum\'s queue.');
|
|
1213
1219
|
alert('Your request is with the moderators.');
|
|
@@ -1326,7 +1332,7 @@ function settingsSaid(text, bad = false) {
|
|
|
1326
1332
|
|
|
1327
1333
|
async function settingsAsk(...activities) {
|
|
1328
1334
|
try {
|
|
1329
|
-
const inbox = await pod.podInboxOf(cats()[0]?.id || forum.id
|
|
1335
|
+
const inbox = await pod.podInboxOf(cats()[0]?.id || forum.id);
|
|
1330
1336
|
for (const activity of activities) {
|
|
1331
1337
|
await pod.moderate({ actor: podAcct.actor, podHome: podAcct.podHome, inbox, activity });
|
|
1332
1338
|
}
|
|
@@ -1404,7 +1410,7 @@ async function removePost(id) {
|
|
|
1404
1410
|
try {
|
|
1405
1411
|
const cat = replyCtx.cat;
|
|
1406
1412
|
if (podAcct) {
|
|
1407
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1413
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1408
1414
|
await pod.remove({ actor: podAcct.actor, podHome: podAcct.podHome, id, category: cat.id,
|
|
1409
1415
|
categoryBase: cat.base, isPrivate: !!cat.private, inbox });
|
|
1410
1416
|
} else {
|
|
@@ -1534,7 +1540,7 @@ $('who-act').addEventListener('click', async () => {
|
|
|
1534
1540
|
async function saveEdit({ body }) {
|
|
1535
1541
|
const cat = replyCtx.cat;
|
|
1536
1542
|
if (podAcct) {
|
|
1537
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1543
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1538
1544
|
return pod.edit({ actor: podAcct.actor, podHome: podAcct.podHome, id: editing.id, text: body, category: cat.id,
|
|
1539
1545
|
categoryBase: cat.base, isPrivate: !!cat.private, inbox });
|
|
1540
1546
|
}
|
|
@@ -1545,7 +1551,7 @@ async function saveEdit({ body }) {
|
|
|
1545
1551
|
// category is named on its own pod, which is where a delivery is taken.
|
|
1546
1552
|
async function postFromPod({ topic, body }) {
|
|
1547
1553
|
const cat = replyCtx.cat;
|
|
1548
|
-
const inbox = await pod.podInboxOf(cat.id
|
|
1554
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
1549
1555
|
if (!inbox) throw new Error('the forum did not say where to send it');
|
|
1550
1556
|
// Posting in an open category joins it on the way past, which is what makes
|
|
1551
1557
|
// the post carry. A private one is joined by asking and being admitted, so
|
|
@@ -1584,7 +1590,8 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1584
1590
|
$('topic-title').value = '';
|
|
1585
1591
|
$('reply-dlg').close();
|
|
1586
1592
|
forgetIndex();
|
|
1587
|
-
|
|
1593
|
+
freshRead = true;
|
|
1594
|
+
try { await route(); } finally { freshRead = false; }
|
|
1588
1595
|
} catch (e) { $('reply-err').textContent = e.message; }
|
|
1589
1596
|
$('reply-send').disabled = false;
|
|
1590
1597
|
});
|
|
@@ -1600,7 +1607,7 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1600
1607
|
if (kept && !sessionStorage.getItem('bb:pod')) {
|
|
1601
1608
|
try {
|
|
1602
1609
|
const s = await pod.session();
|
|
1603
|
-
if (s) { podAcct = { ...JSON.parse(kept), webId: s.webId }; read =
|
|
1610
|
+
if (s) { podAcct = { ...JSON.parse(kept), webId: s.webId }; read = makeReader(s); }
|
|
1604
1611
|
else localStorage.removeItem('bb:acct');
|
|
1605
1612
|
} catch { localStorage.removeItem('bb:acct'); }
|
|
1606
1613
|
}
|
|
@@ -1611,7 +1618,7 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1611
1618
|
const s = params.get('code') ? await pod.complete(location.href) : await pod.session();
|
|
1612
1619
|
if (s) {
|
|
1613
1620
|
podAcct = { ...who, webId: s.webId };
|
|
1614
|
-
read =
|
|
1621
|
+
read = makeReader(s);
|
|
1615
1622
|
try { localStorage.setItem('bb:acct', JSON.stringify(who)); } catch { /* blocked storage */ }
|
|
1616
1623
|
sessionStorage.removeItem('bb:pod');
|
|
1617
1624
|
params.delete('code'); params.delete('state'); params.delete('iss');
|
package/site/pod.mjs
CHANGED
|
@@ -84,18 +84,12 @@ async function placeAndAudience(s, { actor, podHome, category, categoryBase, isP
|
|
|
84
84
|
return { where, to: [categoryBase + 'ap/followers'] };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// so it goes to the pod inbox the Gateway names when asked.
|
|
94
|
-
if (front && handle && categoryActorId.startsWith(front)) {
|
|
95
|
-
const said = await f(`${front}/api/server?host=${encodeURIComponent(new URL(front).host)}&handle=${encodeURIComponent(handle)}`,
|
|
96
|
-
{ headers: { accept: 'application/json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
97
|
-
if (said?.inbox) return said.inbox;
|
|
98
|
-
}
|
|
87
|
+
// Where a category takes what is sent to it: the inbox its actor names. Through
|
|
88
|
+
// a Gateway that is the Gateway's door, which writes the delivery into the
|
|
89
|
+
// forum's pod inbox for whatever runs the forum, and, when the Gateway keeps
|
|
90
|
+
// the forum itself, places the post before it answers. Read from the pod
|
|
91
|
+
// outright, it is the pod inbox.
|
|
92
|
+
export async function podInboxOf(categoryActorId, { fetch: f = globalThis.fetch.bind(globalThis) } = {}) {
|
|
99
93
|
const doc = await f(categoryActorId, { headers: { accept: 'application/activity+json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
100
94
|
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox;
|
|
101
95
|
return typeof inbox === 'string' ? inbox : null;
|
package/src/forum-agent.mjs
CHANGED
|
@@ -30,6 +30,9 @@ import * as moderation from './moderation.mjs';
|
|
|
30
30
|
import * as access from './access.mjs';
|
|
31
31
|
import { provisionForum, provisionCategory } from './provision.mjs';
|
|
32
32
|
import * as settings from './settings.mjs';
|
|
33
|
+
import { keepForum, restateForumRules } from './keep.mjs';
|
|
34
|
+
import { writeForumConfig, attachRows } from './setup.mjs';
|
|
35
|
+
import * as place from './place.mjs';
|
|
33
36
|
|
|
34
37
|
import { ForumIntake } from './forum-intake.mjs';
|
|
35
38
|
export { ForumIntake };
|
|
@@ -44,13 +47,24 @@ const VIEWER_REFRESH_MS = 5 * 60_000;
|
|
|
44
47
|
|
|
45
48
|
export class ForumAgent {
|
|
46
49
|
// `remote` and `storageFor` are injectable so a test can stand a pod in.
|
|
47
|
-
|
|
50
|
+
// A host with no directory of its own — fedipod.net's door or its keeper —
|
|
51
|
+
// hands in the credential, the lease and its holder id instead of files,
|
|
52
|
+
// names the keeper the pod's rules must carry, reads keys and never mints
|
|
53
|
+
// them, and defers every send to the run that follows.
|
|
54
|
+
constructor({ home = null, log = () => {}, remote = null, storageFor = defaultStorageFor, push = true, pollSeconds = null,
|
|
55
|
+
credential = null, lease = null, holderId = null, keepers = [], mintKeys = true, defer = false }) {
|
|
48
56
|
this.home = home;
|
|
49
57
|
this.log = log;
|
|
50
58
|
this.remote = remote;
|
|
51
59
|
this.storageFor = storageFor;
|
|
52
60
|
this.push = push;
|
|
53
61
|
this.pollSeconds = pollSeconds;
|
|
62
|
+
this.credential = credential;
|
|
63
|
+
this.givenLease = lease;
|
|
64
|
+
this.holderId = holderId;
|
|
65
|
+
this.keepers = keepers;
|
|
66
|
+
this.mintKeys = mintKeys;
|
|
67
|
+
this.defer = defer;
|
|
54
68
|
this.categories = [];
|
|
55
69
|
this.viewer = true;
|
|
56
70
|
}
|
|
@@ -64,6 +78,8 @@ export class ForumAgent {
|
|
|
64
78
|
// a moderator's ask — until it expired. Which is what a restart to pick up a
|
|
65
79
|
// fix looked like from the outside: the fix changing nothing.
|
|
66
80
|
hostId() {
|
|
81
|
+
if (this.holderId) return this.holderId;
|
|
82
|
+
if (!this.home) return null;
|
|
67
83
|
const at = path.join(this.home, 'host-id');
|
|
68
84
|
try { return fs.readFileSync(at, 'utf8').trim() || null; } catch { /* not yet */ }
|
|
69
85
|
try {
|
|
@@ -78,10 +94,20 @@ export class ForumAgent {
|
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
readCredential() {
|
|
97
|
+
if (this.credential) return this.credential;
|
|
98
|
+
if (!this.home) return null;
|
|
81
99
|
try { return JSON.parse(fs.readFileSync(path.join(this.home, 'credential.json'), 'utf8')); }
|
|
82
100
|
catch { return null; }
|
|
83
101
|
}
|
|
84
102
|
|
|
103
|
+
// The signing key of one actor, from its pod state. A host that may not
|
|
104
|
+
// mint — the door, which must never invent an identity's key — refuses a
|
|
105
|
+
// missing one instead.
|
|
106
|
+
async keysFor(store, actorId) {
|
|
107
|
+
if (!this.mintKeys && !store.read('keys.json', null)) throw new Error(`no signing key on the pod for ${actorId}`);
|
|
108
|
+
return resolveKeys(store, { localDir: null, actorId, log: this.log });
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
status() {
|
|
86
112
|
return {
|
|
87
113
|
mode: !this.store ? 'unconfigured' : this.viewer ? 'viewer' : 'active',
|
|
@@ -106,46 +132,25 @@ export class ForumAgent {
|
|
|
106
132
|
const cred = this.readCredential();
|
|
107
133
|
if (!cred) throw new Error('no credential.json — make one first');
|
|
108
134
|
await this.attachRemote(cred);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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();
|
|
135
|
+
const { config } = await writeForumConfig(this.remote, this.storageFor, {
|
|
136
|
+
remotePod: cred.remotePod, root: cred.root || ROOT, handle, name, categories, moderators, moderatorWebIds,
|
|
137
|
+
membersOnly, memberWebIds, approveJoins, review, replyPolicy,
|
|
138
|
+
}, { log: this.log });
|
|
139
|
+
return config;
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
async attachRemote(cred) {
|
|
145
|
-
if (this.remote)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
if (!this.remote) {
|
|
144
|
+
const { RemotePod } = await import('fedipod/remote');
|
|
145
|
+
this.remote = new RemotePod(cred, { log: this.log, home: this.home });
|
|
146
|
+
await this.remote.warmup();
|
|
147
|
+
}
|
|
148
|
+
// Every rule this host writes names the keeper beside the owner, and the
|
|
149
|
+
// owner is the forum's, whoever holds the pen.
|
|
150
|
+
if (this.keepers.length) {
|
|
151
|
+
this.remote.keepers = [...this.keepers];
|
|
152
|
+
this.remote.aclOwner = cred.webId || this.remote.aclOwner || null;
|
|
153
|
+
}
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
async connect({ act = true } = {}) {
|
|
@@ -162,7 +167,7 @@ export class ForumAgent {
|
|
|
162
167
|
if (!this.config) { this.log('credential present but the forum has no config — run init'); return false; }
|
|
163
168
|
const front = this.config.gateway?.front || null;
|
|
164
169
|
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,
|
|
170
|
+
this.lease = this.givenLease || new Lease({ url: this.site.state + 'lease.json', fetchImpl: (u, i) => this.remote.fetch(u, i), log: this.log,
|
|
166
171
|
id: this.hostId() });
|
|
167
172
|
this.viewer = act ? !(await this.lease.acquire()) : true;
|
|
168
173
|
this.categories = [];
|
|
@@ -225,11 +230,11 @@ export class ForumAgent {
|
|
|
225
230
|
} } : {}),
|
|
226
231
|
};
|
|
227
232
|
store.setConfig(config);
|
|
228
|
-
const keys = await
|
|
233
|
+
const keys = await this.keysFor(store, urls.actor);
|
|
229
234
|
const cat = { slug, urls, store, config, remote: this.remote };
|
|
230
235
|
cat.deliverer = new Deliverer({
|
|
231
236
|
store, rsaPrivate: keys.rsaPrivate, keyId: urls.actor + '#main-key', actorId: urls.actor,
|
|
232
|
-
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true,
|
|
237
|
+
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(urls), log: this.log, passive: true, defer: this.defer,
|
|
233
238
|
onGone: () => cat.publisher.publishCollections({ followers: true }),
|
|
234
239
|
});
|
|
235
240
|
cat.publisher = new Publisher({
|
|
@@ -254,7 +259,7 @@ export class ForumAgent {
|
|
|
254
259
|
if (doc || !(this.config.membersOnly || []).includes(slug)) return doc;
|
|
255
260
|
return this.podFetchAP(url);
|
|
256
261
|
};
|
|
257
|
-
cat.intake.
|
|
262
|
+
cat.intake.onCarry = (ev) => this.onCarry(cat, ev);
|
|
258
263
|
cat.intake.onReport = (activity, actor, opts) => cat.intake.queueModeration(activity, actor, opts);
|
|
259
264
|
// What happens to a post from somebody who has not joined: dropped, or
|
|
260
265
|
// held where a moderator will see it (the category's `replyPolicy`).
|
|
@@ -298,11 +303,11 @@ export class ForumAgent {
|
|
|
298
303
|
mode: gw.mode || 'trust', hmacSecret: gw.secrets?.[this.config.handle] || null,
|
|
299
304
|
} } : {}),
|
|
300
305
|
};
|
|
301
|
-
const keys = await
|
|
306
|
+
const keys = await this.keysFor(store, site.actor);
|
|
302
307
|
const agent = { urls: site, store, config, remote: this.remote, log: this.log, configured: () => true };
|
|
303
308
|
agent.deliverer = new Deliverer({
|
|
304
309
|
store, rsaPrivate: keys.rsaPrivate, keyId: site.actor + '#main-key', actorId: site.actor,
|
|
305
|
-
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true,
|
|
310
|
+
edPrivate: keys.edPrivate, proofKeyId: assertionKeyId(site), log: this.log, passive: true, defer: this.defer,
|
|
306
311
|
});
|
|
307
312
|
agent.publisher = new Publisher({
|
|
308
313
|
config, remote: this.remote, store, deliverer: agent.deliverer,
|
|
@@ -349,40 +354,27 @@ export class ForumAgent {
|
|
|
349
354
|
return ids.some(id => id === s.actor || id === s.followers);
|
|
350
355
|
}
|
|
351
356
|
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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}`);
|
|
357
|
+
// Placing a post in its topic, and what an edit or a deletion does to the
|
|
358
|
+
// place: place.mjs.
|
|
359
|
+
onCarry(cat, ev) { return place.placePost(this, cat, ev); }
|
|
360
|
+
onCarriedEdit(cat, ev) { return place.editPlaced(this, cat, ev); }
|
|
361
|
+
onCarriedGone(cat, ev) { return place.dropPlaced(this, cat, ev); }
|
|
362
|
+
countReplies(cat, tid, postId) { return place.countReplies(this, cat, tid, postId); }
|
|
363
|
+
noteLatest(cat, note) { return place.noteLatest(this, cat, note); }
|
|
364
|
+
dropLatest(cat, noteId) { return place.dropLatest(this, cat, noteId); }
|
|
365
|
+
|
|
366
|
+
// One delivery, handed in by whoever took it at the forum's door, done the
|
|
367
|
+
// way the drain does one item: handled, every store committed, and only
|
|
368
|
+
// then reported done — so the caller may remove it from the inbox.
|
|
369
|
+
async placeOne(name, body, receipt = null) {
|
|
370
|
+
const done = await this.intake.takeHeld([{ name, body, receipt }]);
|
|
371
|
+
return done.includes(name);
|
|
384
372
|
}
|
|
385
373
|
|
|
374
|
+
// Letting a Gateway keep this forum, and taking that back: keep.mjs.
|
|
375
|
+
keep(o) { return keepForum(this, o); }
|
|
376
|
+
restateRules() { return restateForumRules(this); }
|
|
377
|
+
|
|
386
378
|
// Take addresses at a Gateway: one row for the forum and one per category,
|
|
387
379
|
// each a handle at the front, all writing their mail into the forum's one
|
|
388
380
|
// inbox. The pod session proves the pod; no password leaves this machine.
|
|
@@ -393,28 +385,14 @@ export class ForumAgent {
|
|
|
393
385
|
const cred = this.readCredential();
|
|
394
386
|
if (!cred || !this.store || !this.config) throw new Error('connect first');
|
|
395
387
|
const plain = forumUrls(cred.remotePod, cred.root || ROOT);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 });
|
|
388
|
+
// A test hands in the attach itself; otherwise the pod session proves the owner.
|
|
389
|
+
const f = attachOne
|
|
390
|
+
? async (_url, init) => { const d = await attachOne(JSON.parse(init.body)); return { status: 201, json: async () => d }; }
|
|
391
|
+
: (u, i) => this.remote.session.fetch(u, i);
|
|
392
|
+
const { gateway, handles } = await attachRows({ fetch: f, front: origin, config: this.config, plain }, { log: this.log });
|
|
393
|
+
this.store.setConfig({ ...this.store.getConfig(), gateway, republish: true });
|
|
416
394
|
await this.store.flush();
|
|
417
|
-
return { front: origin, handles
|
|
395
|
+
return { front: origin, handles };
|
|
418
396
|
}
|
|
419
397
|
|
|
420
398
|
// One category's handle at the Gateway, claimed when a moderator creates
|
|
@@ -464,45 +442,6 @@ export class ForumAgent {
|
|
|
464
442
|
await cat.store.flush().catch(() => {});
|
|
465
443
|
}
|
|
466
444
|
|
|
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
445
|
// Queued asks from listed moderators that can be verified at their own
|
|
507
446
|
// origin are applied; everything else waits for a person.
|
|
508
447
|
async applyVerifiedAsks() {
|
|
@@ -752,28 +691,6 @@ export class ForumAgent {
|
|
|
752
691
|
return true;
|
|
753
692
|
}
|
|
754
693
|
|
|
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
694
|
// Everything the forum already holds, read back out of the topics, for an
|
|
778
695
|
// index that did not exist when those posts arrived.
|
|
779
696
|
// Copies kept before the forum recorded which topic they were in: the
|
|
@@ -806,11 +723,6 @@ export class ForumAgent {
|
|
|
806
723
|
return rows.length;
|
|
807
724
|
}
|
|
808
725
|
|
|
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
726
|
async applyModeration(slug, entryId) {
|
|
815
727
|
const cat = this.categories.find(c => c.slug === slug);
|
|
816
728
|
if (!cat) throw new Error(`no such category: ${slug}`);
|
|
@@ -858,8 +770,20 @@ export class ForumAgent {
|
|
|
858
770
|
.catch(e => this.log(`the reader list for ${cat.slug}: ${e.message}`));
|
|
859
771
|
await this.dropUnreadableFollowers(cat)
|
|
860
772
|
.catch(e => this.log(`letting go of ${cat.slug}'s strangers: ${e.message}`));
|
|
861
|
-
|
|
862
|
-
|
|
773
|
+
// Two passes over what older versions wrote, made once and recorded
|
|
774
|
+
// (upkeep.json): each reads every topic or every copy, which is no way
|
|
775
|
+
// to begin every start.
|
|
776
|
+
const upkeep = cat.store.read('upkeep.json', {});
|
|
777
|
+
if (!upkeep.oldTopicsCarried) {
|
|
778
|
+
await this.carryOldTopics(cat);
|
|
779
|
+
cat.store.write('upkeep.json', { ...cat.store.read('upkeep.json', {}), oldTopicsCarried: new Date().toISOString() });
|
|
780
|
+
}
|
|
781
|
+
if (!upkeep.copiesStamped) {
|
|
782
|
+
try {
|
|
783
|
+
await this.stampOldCopies(cat);
|
|
784
|
+
cat.store.write('upkeep.json', { ...cat.store.read('upkeep.json', {}), copiesStamped: new Date().toISOString() });
|
|
785
|
+
} catch (e) { this.log(`stamping ${cat.slug}: ${e.message}`); }
|
|
786
|
+
}
|
|
863
787
|
const seen = cat.store.read('published.json', {});
|
|
864
788
|
if (force || !seen.actorDigest) {
|
|
865
789
|
await cat.publisher.publishProfile({ force });
|
package/src/forum-intake.mjs
CHANGED
|
@@ -78,6 +78,20 @@ export class ForumIntake extends Intake {
|
|
|
78
78
|
return ok;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Deliveries handed in rather than listed — one placed at the door, a batch
|
|
82
|
+
// the door held — are done only when every category's state is on the pod
|
|
83
|
+
// too, the same rule the drain keeps.
|
|
84
|
+
async takeHeld(entries) {
|
|
85
|
+
for (const cat of this.forum.categories) cat.store.hold?.();
|
|
86
|
+
let done;
|
|
87
|
+
try { done = await super.takeHeld(entries); }
|
|
88
|
+
finally { for (const cat of this.forum.categories) cat.store.release?.(); }
|
|
89
|
+
if (!done.length) return done;
|
|
90
|
+
let ok = true;
|
|
91
|
+
for (const cat of this.forum.categories) ok = (await cat.store.commit()) && ok;
|
|
92
|
+
return ok ? done : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
// Categories carry (FEP-1b12); the forum's own inbox forwards nothing.
|
|
82
96
|
async _maybeForward() {}
|
|
83
97
|
|
|
@@ -97,6 +111,16 @@ export class ForumIntake extends Intake {
|
|
|
97
111
|
} catch { return null; }
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
// A receipt handed in with a delivery, the same way: any of the forum's
|
|
115
|
+
// secrets may have signed it.
|
|
116
|
+
async _checkReceipt(receipt) {
|
|
117
|
+
if (!receipt || typeof receipt !== 'object') return null;
|
|
118
|
+
const secrets = [this.config.gateway?.hmacSecret, ...this.forum.categories.map(c => c.config.gateway?.hmacSecret)].filter(Boolean);
|
|
119
|
+
if (!secrets.length) return null;
|
|
120
|
+
const { verifyReceipt } = await import('fedipod/gateway/httpsig.mjs');
|
|
121
|
+
return secrets.some(s => verifyReceipt(receipt, s)) ? receipt : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
gatewaySecret() {
|
|
101
125
|
return this.config.gateway?.hmacSecret || this.forum.categories.find(c => c.config.gateway?.hmacSecret)?.config.gateway.hmacSecret || null;
|
|
102
126
|
}
|
package/src/gateway.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// gateway.mjs — the forum as fedipod.net runs it, with nothing running
|
|
2
|
+
// anywhere else. Two entries: placeAtDoor(), called by the door with the
|
|
3
|
+
// delivery it just wrote into the forum's inbox, places it in the same
|
|
4
|
+
// request; keepOnce(), the forum's run, carries what the door placed, drains
|
|
5
|
+
// what it left, applies moderators' asks and writes the heartbeat. Both work
|
|
6
|
+
// as the gateway's keeper identity, from the copies the gateway holds of the
|
|
7
|
+
// forum's and each category's state, under the forum's one copy lease.
|
|
8
|
+
|
|
9
|
+
import { PodTransport } from 'fedipod/pod/transport.mjs';
|
|
10
|
+
import { podBaseOfWebId } from 'fedipod/pod/urls.mjs';
|
|
11
|
+
import * as podInbox from 'fedipod/pod/inbox.mjs';
|
|
12
|
+
import { HttpStorage } from 'fedipod/core/storage.mjs';
|
|
13
|
+
import { Lease } from 'fedipod/core/lease.mjs';
|
|
14
|
+
import { CopyStorage, copyMeta, copyLease, lockCopy, GATEWAY_HOLDER } from 'fedipod/gateway/copy.mjs';
|
|
15
|
+
import { ensureCopy } from 'fedipod/gateway/state-api.mjs';
|
|
16
|
+
import { keeperSession } from 'fedipod/gateway/keeper-session.mjs';
|
|
17
|
+
import { ForumAgent } from './forum-agent.mjs';
|
|
18
|
+
import * as publish from './publish.mjs';
|
|
19
|
+
|
|
20
|
+
// How soon a run comes back for an ask that could not be applied yet.
|
|
21
|
+
const ASK_RETRY_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
/** A forum's own row, or one of its categories': both write into one inbox. */
|
|
24
|
+
export const isForumRow = (rec) => !!rec?.inboxUrl && (rec.kind === 'group' || rec.kind === 'application');
|
|
25
|
+
|
|
26
|
+
/** The forum a row belongs to: its handle and row, and where it is on the pod. */
|
|
27
|
+
export async function forumFor(ctx, handle, rec) {
|
|
28
|
+
const forumHandle = rec.kind === 'application' ? handle : rec.forum;
|
|
29
|
+
if (!forumHandle) return { skipped: 'the category names no forum; run `fedipod-bb keep` again' };
|
|
30
|
+
const forumRec = forumHandle === handle ? rec : await ctx.lookup(forumHandle);
|
|
31
|
+
if (!forumRec || !isForumRow(forumRec) || !forumRec.keeper) return { skipped: `@${forumHandle} is not a forum this gateway keeps` };
|
|
32
|
+
if (!forumRec.webId || !forumRec.podHome) return { skipped: 'the forum row names no owner or pod' };
|
|
33
|
+
const home = forumRec.podHome.replace(/\/?$/u, '/');
|
|
34
|
+
const pod = podBaseOfWebId(forumRec.webId);
|
|
35
|
+
if (!home.startsWith(pod)) return { skipped: "the forum's folder is not on its owner's pod" };
|
|
36
|
+
return { forumHandle, forumRec, home, pod, root: home.slice(pod.length) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The gateway's pod fetch, as the keeper: a session handed in, the keeper's
|
|
40
|
+
// credential, or the front's own keeperFetch.
|
|
41
|
+
async function keeperFetchFor(ctx, session) {
|
|
42
|
+
if (session) return (u, i) => session.fetch(u, i);
|
|
43
|
+
if (ctx.keeperCredential) { const s = keeperSession(ctx.keeperCredential); return (u, i) => s.fetch(u, i); }
|
|
44
|
+
return ctx.keeperFetch ? ctx.keeperFetch() : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function transportFor(ctx, f, podFetch, log) {
|
|
48
|
+
const remote = new PodTransport({ fetch: podFetch }, { webId: ctx.keeperWebId, log, role: 'keeper', runtime: 'node', cooldownMode: 'refuse' });
|
|
49
|
+
// Any rule it writes names the forum's owner, and the keeper beside them.
|
|
50
|
+
remote.aclOwner = f.forumRec.webId;
|
|
51
|
+
remote.keepers = [ctx.keeperWebId];
|
|
52
|
+
remote.aclIfChanged = true;
|
|
53
|
+
return remote;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Which copy holds a state container: the forum's own, or a category's. The
|
|
57
|
+
// inbox archive and anything else stays the pod's.
|
|
58
|
+
function copyHandleOf(base, f) {
|
|
59
|
+
if (base === f.home + 'ap-state/') return f.forumHandle;
|
|
60
|
+
const m = base.startsWith(f.home + 'c/') ? /^c\/([^/]+)\/ap-state\/$/u.exec(base.slice(f.home.length)) : null;
|
|
61
|
+
return m ? m[1] : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** State storage for the forum: the copy where one exists, fenced by the forum's lease; the pod otherwise. */
|
|
65
|
+
export function storageFactory(ctx, f, podFetch, copies) {
|
|
66
|
+
return (base) => {
|
|
67
|
+
const pod = new HttpStorage(base, podFetch);
|
|
68
|
+
const h = copyHandleOf(base, f);
|
|
69
|
+
if (!h || !copies.has(h)) return pod;
|
|
70
|
+
return new CopyStorage(ctx.copyKv, h, { holder: GATEWAY_HOLDER, pod, podNames: ['keys.json'], fence: f.forumHandle });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readConfig(ctx, f, podFetch) {
|
|
75
|
+
const storage = await copyMeta(ctx.copyKv, f.forumHandle)
|
|
76
|
+
? new CopyStorage(ctx.copyKv, f.forumHandle, { holder: GATEWAY_HOLDER })
|
|
77
|
+
: new HttpStorage(f.home + 'ap-state/', podFetch);
|
|
78
|
+
const r = await storage.read('config.json', { accept: 'application/json' }).catch(() => null);
|
|
79
|
+
if (!r?.ok || !r.body) return null;
|
|
80
|
+
try { return JSON.parse(r.body); } catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The copies the forum works from, the forum's and each category's, made
|
|
85
|
+
* from the pod where missing. A copy cannot be made while another host
|
|
86
|
+
* holds the pod's lease, which is how a forum still run elsewhere is left
|
|
87
|
+
* alone until that host stops.
|
|
88
|
+
*/
|
|
89
|
+
export async function ensureForumCopies(ctx, f, config, log) {
|
|
90
|
+
const rows = [[f.forumHandle, f.forumRec]];
|
|
91
|
+
for (const c of config?.categories || []) {
|
|
92
|
+
const r = await ctx.lookup(c.slug);
|
|
93
|
+
if (r && isForumRow(r)) rows.push([c.slug, r]);
|
|
94
|
+
}
|
|
95
|
+
const have = new Set();
|
|
96
|
+
for (const [h, r] of rows) {
|
|
97
|
+
if (await copyMeta(ctx.copyKv, h)) { have.add(h); continue; }
|
|
98
|
+
const made = await ensureCopy(ctx, h, r, log);
|
|
99
|
+
if (made.ok) have.add(h); else log(`copy @${h}: ${made.why}`);
|
|
100
|
+
}
|
|
101
|
+
return have;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which copies exist now, without making any. */
|
|
105
|
+
async function copiesOf(ctx, f, config) {
|
|
106
|
+
const have = new Set();
|
|
107
|
+
if (await copyMeta(ctx.copyKv, f.forumHandle)) have.add(f.forumHandle);
|
|
108
|
+
for (const c of config?.categories || []) if (await copyMeta(ctx.copyKv, c.slug)) have.add(c.slug);
|
|
109
|
+
return have;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function agentFor(ctx, f, { remote, podFetch, copies, log, mode }) {
|
|
113
|
+
const lease = copies.has(f.forumHandle)
|
|
114
|
+
? copyLease(ctx.copyKv, f.forumHandle, { id: GATEWAY_HOLDER, log })
|
|
115
|
+
: new Lease({ url: f.home + 'ap-state/lease.json', fetchImpl: podFetch, log, id: `keeper:${ctx.keeperWebId}` });
|
|
116
|
+
return new ForumAgent({
|
|
117
|
+
log, remote, storageFor: storageFactory(ctx, f, podFetch, copies), push: false,
|
|
118
|
+
credential: { remotePod: f.pod, root: f.root, webId: f.forumRec.webId },
|
|
119
|
+
lease, holderId: GATEWAY_HOLDER, keepers: [ctx.keeperWebId],
|
|
120
|
+
// The run may mint a new forum's first keys; the door never mints.
|
|
121
|
+
mintKeys: mode === 'run', defer: mode === 'door',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// When the run should come back on its own: a delivery's next try, or an ask
|
|
126
|
+
// that could not be applied yet.
|
|
127
|
+
function nextRun(forum, asksPending) {
|
|
128
|
+
const times = [];
|
|
129
|
+
for (const s of [forum.store, ...forum.categories.map((c) => c.store)]) {
|
|
130
|
+
for (const item of s.getQueue?.() || []) if (item.nextAt) times.push(item.nextAt);
|
|
131
|
+
}
|
|
132
|
+
if (asksPending) times.push(Date.now() + ASK_RETRY_MS);
|
|
133
|
+
return times.length ? Math.min(...times) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One run of the forum: what the door left in the inbox is drained, what it
|
|
138
|
+
* placed is carried, moderators' asks are applied, the heartbeat is written.
|
|
139
|
+
* Returns what the keeper's bookkeeping expects: { drained, waiting, nextAt }
|
|
140
|
+
* or { skipped, retry }.
|
|
141
|
+
*/
|
|
142
|
+
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null, remote: given = null, prepare = null } = {}) {
|
|
143
|
+
const f = await forumFor(ctx, handle, rec);
|
|
144
|
+
if (f.skipped) return { skipped: f.skipped };
|
|
145
|
+
const say = (m) => log(`forum @${f.forumHandle}: ${m}`);
|
|
146
|
+
const podFetch = given ? (u, i) => given.fetch(u, i) : await keeperFetchFor(ctx, session);
|
|
147
|
+
if (!podFetch) return { skipped: 'this gateway cannot reach pods for accounts' };
|
|
148
|
+
const remote = given || transportFor(ctx, f, podFetch, say);
|
|
149
|
+
const config = await readConfig(ctx, f, podFetch);
|
|
150
|
+
if (!config) return { skipped: 'no forum on the pod' };
|
|
151
|
+
const copies = await ensureForumCopies(ctx, f, config, say);
|
|
152
|
+
const unlock = await lockCopy(ctx.copyKv, f.forumHandle);
|
|
153
|
+
if (!unlock) return { skipped: 'the gateway is busy with this forum', retry: 'soon' };
|
|
154
|
+
try {
|
|
155
|
+
const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
|
|
156
|
+
if (!await forum.connect({ act: false })) return { skipped: 'no forum on the pod' };
|
|
157
|
+
prepare?.(forum); // a test's origins, in place of the network
|
|
158
|
+
if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
|
|
159
|
+
try {
|
|
160
|
+
await forum.publishAll();
|
|
161
|
+
await forum.intake.drain();
|
|
162
|
+
await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
|
|
163
|
+
await forum.publishModQueue().catch((e) => say(`queue: ${e.message}`));
|
|
164
|
+
for (const cat of forum.categories) await cat.deliverer.drainQueue();
|
|
165
|
+
await forum.siteAgent.deliverer.drainQueue();
|
|
166
|
+
await publish.publishHeartbeat(forum.siteAgent).catch((e) => say(`heartbeat: ${e.message}`));
|
|
167
|
+
await Promise.allSettled([forum.store.flush(), ...forum.categories.map((c) => c.store.flush())]);
|
|
168
|
+
const waiting = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + (s.getQueue?.() || []).length, 0);
|
|
169
|
+
const asks = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
|
|
170
|
+
// The run wrote on the pod (what the door could not place, the queue,
|
|
171
|
+
// the heartbeat): the edge's copies of the forum's documents go.
|
|
172
|
+
if (ctx.purge) {
|
|
173
|
+
const tags = [`u-${f.forumHandle}`, ...(config.categories || []).map((c) => `u-${c.slug}`)];
|
|
174
|
+
await ctx.purge(tags).catch((e) => say(`purge: ${e.message}`));
|
|
175
|
+
}
|
|
176
|
+
say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
|
|
177
|
+
return { drained: true, waiting, nextAt: nextRun(forum, asks) };
|
|
178
|
+
} finally { await forum.lease.release().catch(() => {}); }
|
|
179
|
+
} finally { await unlock(); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The delivery the door just wrote into the forum's inbox, placed now: the
|
|
184
|
+
* forum handles it with every send deferred, and when it is done the item
|
|
185
|
+
* and its receipt leave the inbox. Returns { placed, forum, why }. Whatever
|
|
186
|
+
* is not placed here stays in the inbox for the run the caller starts.
|
|
187
|
+
*/
|
|
188
|
+
export async function placeAtDoor(ctx, handle, rec, { name, raw, receipt = null, log = console.log, waitMs = 3000, remote: given = null, prepare = null } = {}) {
|
|
189
|
+
const f = await forumFor(ctx, handle, rec);
|
|
190
|
+
if (f.skipped) return { placed: false, why: f.skipped };
|
|
191
|
+
const out = { placed: false, forum: f.forumHandle };
|
|
192
|
+
if (!await copyMeta(ctx.copyKv, f.forumHandle)) return { ...out, why: 'the forum has no copy yet' };
|
|
193
|
+
const podFetch = given ? (u, i) => given.fetch(u, i) : await keeperFetchFor(ctx, null);
|
|
194
|
+
if (!podFetch) return { ...out, why: 'this gateway cannot reach pods for accounts' };
|
|
195
|
+
const say = (m) => log(`door @${f.forumHandle}: ${m}`);
|
|
196
|
+
const remote = given || transportFor(ctx, f, podFetch, say);
|
|
197
|
+
const config = await readConfig(ctx, f, podFetch);
|
|
198
|
+
if (!config) return { ...out, why: 'no forum on the pod' };
|
|
199
|
+
const copies = await copiesOf(ctx, f, config);
|
|
200
|
+
const unlock = await lockCopy(ctx.copyKv, f.forumHandle, { waitMs });
|
|
201
|
+
if (!unlock) return { ...out, why: 'the forum is busy' };
|
|
202
|
+
try {
|
|
203
|
+
const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'door' });
|
|
204
|
+
if (!await forum.connect({ act: false })) return { ...out, why: 'no forum on the pod' };
|
|
205
|
+
prepare?.(forum); // a test's origins, in place of the network
|
|
206
|
+
if (!await forum.lease.acquire()) return { ...out, why: 'another host has the forum' };
|
|
207
|
+
try {
|
|
208
|
+
if (!await forum.placeOne(name, raw, receipt)) return { ...out, why: 'not done here; left for the run' };
|
|
209
|
+
const item = f.home + 'ap/inbox/' + name;
|
|
210
|
+
await podInbox.dropHandledItem(remote, item).catch(() => false);
|
|
211
|
+
await podInbox.dropReceiptBeside(remote, item);
|
|
212
|
+
return { ...out, placed: true };
|
|
213
|
+
} finally { await forum.lease.release().catch(() => {}); }
|
|
214
|
+
} finally { await unlock(); }
|
|
215
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -6,4 +6,5 @@ export * as topics from './topics.mjs';
|
|
|
6
6
|
export * as publish from './publish.mjs';
|
|
7
7
|
export * as moderation from './moderation.mjs';
|
|
8
8
|
export { provisionForum, provisionCategory } from './provision.mjs';
|
|
9
|
+
export * as setup from './setup.mjs';
|
|
9
10
|
export { ForumAgent, ForumIntake } from './forum-agent.mjs';
|
package/src/keep.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// keep.mjs — letting a Gateway keep this forum: it acts for the forum when
|
|
2
|
+
// nothing else does, from the pod's own rules, which name its keeper beside
|
|
3
|
+
// the owner from then on. Taking the name out again is the same in reverse.
|
|
4
|
+
|
|
5
|
+
import { restateRules } from 'fedipod/pod/containers.mjs';
|
|
6
|
+
import * as podInbox from 'fedipod/pod/inbox.mjs';
|
|
7
|
+
import * as publish from './publish.mjs';
|
|
8
|
+
import { provisionForum, provisionCategory } from './provision.mjs';
|
|
9
|
+
import { keepRows } from './setup.mjs';
|
|
10
|
+
|
|
11
|
+
// Let a Gateway keep this forum: it acts for the forum when nothing else
|
|
12
|
+
// does, from the pod's own rules, which name it beside the owner from here
|
|
13
|
+
// on. `on: false` takes the name out again.
|
|
14
|
+
export async function keepForum(forum, { front, on = true }) {
|
|
15
|
+
const { keeper, handles } = await keepRows({ fetch: (u, i) => forum.remote.session.fetch(u, i), front, config: forum.config, on });
|
|
16
|
+
forum.keepers = on && keeper ? [keeper] : [];
|
|
17
|
+
forum.remote.keepers = [...forum.keepers];
|
|
18
|
+
forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
|
|
19
|
+
await forum.restateRules();
|
|
20
|
+
return { keeper: on ? keeper : null, handles, on };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Every rule on the forum's tree, stated again with the names this host
|
|
24
|
+
// carries: the containers, the inboxes, the reader lists, and the documents
|
|
25
|
+
// that state a rule of their own.
|
|
26
|
+
export async function restateForumRules(forum) {
|
|
27
|
+
await provisionForum(forum.remote, forum.site, { moderatorWebIds: forum.config.moderatorWebIds || [] });
|
|
28
|
+
await restateRules(forum.remote, forum.site).catch(e => forum.log(`restating the forum's rules: ${e.message}`));
|
|
29
|
+
await podInbox.setPosture(forum.remote, forum.site, 'open');
|
|
30
|
+
for (const cat of forum.categories) {
|
|
31
|
+
await provisionCategory(forum.remote, cat.urls, { memberWebIds: forum.membersOf(cat) });
|
|
32
|
+
await restateRules(forum.remote, cat.urls).catch(e => forum.log(`restating ${cat.slug}'s rules: ${e.message}`));
|
|
33
|
+
await publish.publishMembers(cat, forum.readersOf(cat), { force: true }).catch(e => forum.log(`the reader list for ${cat.slug}: ${e.message}`));
|
|
34
|
+
await podInbox.setPosture(forum.remote, cat.urls, 'closed');
|
|
35
|
+
for (const base of [cat.urls.topicContainer, cat.urls.cache]) {
|
|
36
|
+
const children = await forum.remote.listContainer?.(base).catch(() => []) || [];
|
|
37
|
+
for (const child of children) if (!child.url.endsWith('/')) await forum.remote.restateAcl?.(child.url).catch(() => null);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/place.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// place.mjs — placing a post in its topic, keeping the copy readers see, and
|
|
2
|
+
// what an edit or a deletion does to that place. Called by a category's
|
|
3
|
+
// intake before it carries a post, and by the door that takes one as it
|
|
4
|
+
// lands.
|
|
5
|
+
|
|
6
|
+
import * as topics from './topics.mjs';
|
|
7
|
+
import * as publish from './publish.mjs';
|
|
8
|
+
import * as moderation from './moderation.mjs';
|
|
9
|
+
|
|
10
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
11
|
+
|
|
12
|
+
// Before a category carries a post: place it in its topic, keep a copy for
|
|
13
|
+
// readers, and publish what changed. A locked topic takes no more: the post
|
|
14
|
+
// is not placed and, answered with false, not carried either.
|
|
15
|
+
export async function placePost(forum, cat, { noteId, sent }) {
|
|
16
|
+
let note = cat.intake.recentNotes.get(noteId) || null;
|
|
17
|
+
cat.intake.recentNotes.delete(noteId);
|
|
18
|
+
if (!note) note = await cat.intake.fetchAP(noteId);
|
|
19
|
+
if (!note) throw new Error(`${noteId} could not be read for its topic`);
|
|
20
|
+
const tid = await topics.assign({ store: cat.store, urls: cat.urls, fetchAP: (u) => cat.intake.fetchAP(u) }, note, sent);
|
|
21
|
+
if (moderation.isLocked(cat, tid) && !topics.list(cat.store).find(t => t.tid === tid && t.op === noteId)) {
|
|
22
|
+
topics.remove(cat.store, tid, noteId);
|
|
23
|
+
forum.log(`${cat.slug}: ${noteId} not placed — topic ${tid} is locked`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
await publish.cachePost(cat, note, { topic: cat.urls.topic(tid), replies: 0 });
|
|
27
|
+
noteLatest(forum, cat, note);
|
|
28
|
+
const answered = idOf(note.inReplyTo);
|
|
29
|
+
if (answered) await countReplies(forum, cat, tid, answered).catch(e => forum.log(`replies of ${answered}: ${e.message}`));
|
|
30
|
+
// The author's card, from the actor the intake already holds; fetched
|
|
31
|
+
// once when it does not, so the website can name them.
|
|
32
|
+
const author = idOf([].concat(note.attributedTo || [])[0]);
|
|
33
|
+
if (author) {
|
|
34
|
+
const held = cat.store.getActors?.()[author];
|
|
35
|
+
const doc = held ? { id: author, ...held } : await cat.intake.fetchAP(author).catch(() => null);
|
|
36
|
+
if (doc) await publish.cacheAuthor(cat, doc).catch(e => forum.log(`author card for ${author}: ${e.message}`));
|
|
37
|
+
}
|
|
38
|
+
await publish.publishTopic(cat, tid);
|
|
39
|
+
await publish.publishTopicIndex(cat);
|
|
40
|
+
await publish.publishLatest(forum.siteAgent);
|
|
41
|
+
forum.log(`${cat.slug}: ${noteId} in topic ${tid}`);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// An author edited a post of theirs that we hold: the copy the website
|
|
46
|
+
// reads is rewritten from the note as verified at its origin, and a changed
|
|
47
|
+
// title is the topic's title when that post opened it.
|
|
48
|
+
export async function editPlaced(forum, cat, { noteId, note }) {
|
|
49
|
+
// The copy is rewritten from the note as verified at its origin; the
|
|
50
|
+
// counts the forum keeps on it are put back from what the forum knows.
|
|
51
|
+
const v = cat.store.read('votes.json', {})[noteId];
|
|
52
|
+
const held = topics.topicOf(cat.store, noteId);
|
|
53
|
+
const replies = held ? (topics.get(cat.store, held)?.posts || []).filter(p => p.inReplyTo === noteId).length : null;
|
|
54
|
+
await publish.cachePost(cat, note, { topic: held ? cat.urls.topic(held) : null, replies,
|
|
55
|
+
likes: (Array.isArray(v) ? v : v?.up || []).length, dislikes: (Array.isArray(v) ? [] : v?.down || []).length });
|
|
56
|
+
await publish.publishLatest(forum.siteAgent);
|
|
57
|
+
const tid = topics.topicOf(cat.store, noteId);
|
|
58
|
+
if (!tid) return;
|
|
59
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// An author deleted one: it leaves the topic, its copy becomes a tombstone
|
|
63
|
+
// (FEP-4f05), and a topic with nothing left in it goes too. The carry has
|
|
64
|
+
// already been taken back by the group itself.
|
|
65
|
+
export async function dropPlaced(forum, cat, { noteId }) {
|
|
66
|
+
const tid = topics.topicOf(cat.store, noteId);
|
|
67
|
+
// Whatever it answered has one fewer answer now.
|
|
68
|
+
const answered = tid ? idOf(topics.get(cat.store, tid)?.posts?.find(p => p.id === noteId)?.inReplyTo) : null;
|
|
69
|
+
dropLatest(forum, cat, noteId);
|
|
70
|
+
await publish.tombstoneCached(cat, noteId);
|
|
71
|
+
await publish.publishLatest(forum.siteAgent);
|
|
72
|
+
if (!tid) return;
|
|
73
|
+
topics.remove(cat.store, tid, noteId);
|
|
74
|
+
const left = topics.get(cat.store, tid);
|
|
75
|
+
if (!left?.posts?.length) {
|
|
76
|
+
await moderation.deleteTopic(cat, tid).catch(e => forum.log(`empty topic ${tid}: ${e.message}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
80
|
+
await publish.publishTopicIndex(cat, { force: true });
|
|
81
|
+
if (answered) await countReplies(forum, cat, tid, answered).catch(e => forum.log(`replies of ${answered}: ${e.message}`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Answers to one post, counted in the topic that holds it, and written
|
|
85
|
+
// into the copy the website reads.
|
|
86
|
+
export async function countReplies(forum, cat, tid, postId) {
|
|
87
|
+
const doc = topics.get(cat.store, tid);
|
|
88
|
+
if (!doc) return;
|
|
89
|
+
const n = (doc.posts || []).filter(p => p.inReplyTo === postId).length;
|
|
90
|
+
const copy = await forum.remote.getJson(cat.urls.cached(postId)).catch(() => null);
|
|
91
|
+
if (!copy || copy.type === 'Tombstone') return;
|
|
92
|
+
if (Number(copy.replies?.totalItems) === n) return;
|
|
93
|
+
await publish.cachePost(cat, copy, { replies: n });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The forum's own index of its newest posts, across every category.
|
|
97
|
+
export function noteLatest(forum, cat, note) {
|
|
98
|
+
const copy = cat.urls.cached(note.id);
|
|
99
|
+
const at = note.published || new Date().toISOString();
|
|
100
|
+
const rows = forum.store.read('latest.json', []).filter(e => e.copy !== copy);
|
|
101
|
+
rows.push({ copy, at });
|
|
102
|
+
rows.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
103
|
+
forum.store.write('latest.json', rows.slice(0, publish.LATEST_MAX));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function dropLatest(forum, cat, noteId) {
|
|
107
|
+
const copy = cat.urls.cached(noteId);
|
|
108
|
+
forum.store.write('latest.json', forum.store.read('latest.json', []).filter(e => e.copy !== copy));
|
|
109
|
+
}
|
|
110
|
+
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// setup.mjs — bringing a forum into being on its pod, from wherever its owner
|
|
2
|
+
// is: a moderator's machine (`fedipod-bb init`, `attach`, `keep`) or the
|
|
3
|
+
// browser at a Gateway's sign-up page. Config, containers and rules, the rows
|
|
4
|
+
// at a Gateway and the keeper's name: nothing here runs the forum. Every
|
|
5
|
+
// function takes the pod transport and the fetch it is handed; none reaches
|
|
6
|
+
// for a file, a credential or the network on its own, so the same code runs
|
|
7
|
+
// in Node and in a page.
|
|
8
|
+
|
|
9
|
+
import { restateRules } from 'fedipod/pod/containers.mjs';
|
|
10
|
+
import { forumUrls, ROOT, isSlug } from './urls.mjs';
|
|
11
|
+
|
|
12
|
+
export { ROOT };
|
|
13
|
+
import { provisionForum, provisionCategory } from './provision.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The forum's config, from what it was and what is asked. A renamed forum or
|
|
17
|
+
* a changed reader list has to be published again — the actors and the rules
|
|
18
|
+
* are only rewritten when something asks — so the config says so.
|
|
19
|
+
*/
|
|
20
|
+
export function forumConfig(existing = {}, { remotePod, root = ROOT, handle, name, categories = [], moderators = [],
|
|
21
|
+
moderatorWebIds = [], membersOnly = [], memberWebIds = {}, approveJoins = false, review = false, replyPolicy = 'open' }) {
|
|
22
|
+
if (!isSlug(handle)) throw new Error(`not a forum handle: ${handle}`);
|
|
23
|
+
const cats = categories.map(c => (typeof c === 'string' ? { slug: c, name: c } : c));
|
|
24
|
+
for (const c of cats) if (!isSlug(c.slug)) throw new Error(`not a category slug: ${c.slug}`);
|
|
25
|
+
const was = JSON.stringify((existing.categories || []).map(c => [c.slug, c.name]));
|
|
26
|
+
const access = JSON.stringify([existing.membersOnly || [], existing.memberWebIds || {}, existing.moderatorWebIds || []]);
|
|
27
|
+
const accessChanged = !!existing.handle && access !== JSON.stringify([membersOnly, memberWebIds, moderatorWebIds]);
|
|
28
|
+
const renamed = !!existing.handle
|
|
29
|
+
&& ((name || handle) !== existing.name || was !== JSON.stringify(cats.map(c => [c.slug, c.name])));
|
|
30
|
+
return {
|
|
31
|
+
...existing, kind: 'application', handle, name: name || existing.name || handle,
|
|
32
|
+
...(renamed || accessChanged ? { republish: true } : {}),
|
|
33
|
+
...(accessChanged ? { reprovision: true } : {}),
|
|
34
|
+
remotePod, root,
|
|
35
|
+
categories: cats, moderators, moderatorWebIds, membersOnly, memberWebIds,
|
|
36
|
+
approveJoins, review, replyPolicy,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The config document, written the way the state store writes it (one JSON
|
|
41
|
+
// document per name in the state container), without the store itself: a
|
|
42
|
+
// page that makes a forum need not carry the store's whole apparatus.
|
|
43
|
+
const CONFIG = 'config.json';
|
|
44
|
+
const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
|
|
45
|
+
async function writeConfig(storage, config) {
|
|
46
|
+
const w = await storage.write(CONFIG, serialise(config), 'application/json');
|
|
47
|
+
if (!w.ok) throw new Error(`the forum's config could not be written${w.why ? ` (${w.why})` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first act on a pod: the forum's containers and its config, nothing
|
|
52
|
+
* else — the actors are published by the first run. `storageFor(base,
|
|
53
|
+
* fetch)` is the owner-only state storage the caller uses. Returns the
|
|
54
|
+
* storage, the config and the forum's plain urls, for whatever the caller
|
|
55
|
+
* does next.
|
|
56
|
+
*/
|
|
57
|
+
export async function writeForumConfig(remote, storageFor, opts, { log = () => {} } = {}) {
|
|
58
|
+
const plain = forumUrls(opts.remotePod, opts.root || ROOT);
|
|
59
|
+
await provisionForum(remote, plain);
|
|
60
|
+
const storage = storageFor(plain.state, (u, i) => remote.fetch(u, i));
|
|
61
|
+
const had = await storage.read(CONFIG).catch(() => null);
|
|
62
|
+
let existing = {};
|
|
63
|
+
if (had?.ok && had.body) { try { existing = JSON.parse(had.body) || {}; } catch { existing = {}; } }
|
|
64
|
+
const config = forumConfig(existing, opts);
|
|
65
|
+
await writeConfig(storage, config);
|
|
66
|
+
log(`forum ${config.handle} initialised with ${config.categories.length} categor${config.categories.length === 1 ? 'y' : 'ies'}`);
|
|
67
|
+
return { storage, config, plain };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The forum's rows at a Gateway: its own and one per category, all writing into its one inbox. */
|
|
71
|
+
export const gatewayRows = (config, plain) => [
|
|
72
|
+
{ handle: config.handle, podHome: plain.home, actorUrl: plain.actor, kind: 'application' },
|
|
73
|
+
...(config.categories || []).map(c => ({ handle: c.slug, podHome: plain.category(c.slug).home,
|
|
74
|
+
actorUrl: plain.category(c.slug).actor, kind: 'group' })),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Attach the forum at a Gateway: each row fronted, its receipts' secret kept.
|
|
79
|
+
* `fetch` carries the owner's proof (the pod session). Returns what the config
|
|
80
|
+
* records under `gateway`, and the handles.
|
|
81
|
+
*/
|
|
82
|
+
export async function attachRows({ fetch: f, front, config, plain }, { log = () => {} } = {}) {
|
|
83
|
+
const origin = String(front).replace(/\/+$/u, '');
|
|
84
|
+
const secrets = {};
|
|
85
|
+
const rows = gatewayRows(config, plain);
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const res = await f(`${origin}/api/attach`, {
|
|
88
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ ...row, fronted: true, inboxUrl: plain.inbox }),
|
|
90
|
+
});
|
|
91
|
+
const d = await res.json().catch(() => ({}));
|
|
92
|
+
if (res.status !== 201 || !d.hmacSecret) throw new Error(`attach ${row.handle} at ${origin}: HTTP ${res.status}${d.error ? ' ' + d.error : ''}`);
|
|
93
|
+
secrets[row.handle] = String(d.hmacSecret);
|
|
94
|
+
log(`attached @${row.handle}@${new URL(origin).host}`);
|
|
95
|
+
}
|
|
96
|
+
return { gateway: { front: origin, mode: 'trust', secrets }, handles: rows.map(r => r.handle) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Let a Gateway keep the forum (or stop): every row, the categories first and
|
|
101
|
+
* the forum's own row last, because turning the forum's row on is what starts
|
|
102
|
+
* its first run, and that run wants every category kept already. Returns the
|
|
103
|
+
* keeper's WebID.
|
|
104
|
+
*/
|
|
105
|
+
export async function keepRows({ fetch: f, front, config, on = true }) {
|
|
106
|
+
const origin = String(front).replace(/\/+$/u, '');
|
|
107
|
+
const forumHandle = config.handle;
|
|
108
|
+
const handles = [...(config.categories || []).map(c => c.slug), forumHandle];
|
|
109
|
+
let keeper = null;
|
|
110
|
+
for (const handle of handles) {
|
|
111
|
+
const res = await f(`${origin}/api/keeper`, {
|
|
112
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ handle, on, ...(handle === forumHandle ? {} : { forum: forumHandle }) }),
|
|
114
|
+
});
|
|
115
|
+
const d = await res.json().catch(() => ({}));
|
|
116
|
+
if (!res.ok) throw new Error(`${origin} would not ${on ? 'keep' : 'let go of'} @${handle}: ${d.error || res.status}`);
|
|
117
|
+
keeper = d.keeper || keeper;
|
|
118
|
+
}
|
|
119
|
+
return { keeper: on ? keeper : null, handles, on };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The forum's rules stated with the keeper's name beside the owner's (or
|
|
124
|
+
* without it): the containers and what they inherit. For a forum that has
|
|
125
|
+
* members and readers already, the host's own restatement (keep.mjs) also
|
|
126
|
+
* rewrites the reader lists; a forum just made has none.
|
|
127
|
+
*/
|
|
128
|
+
export async function nameKeeperInRules(remote, { config, plain, keeper = null, ownerWebId = null }) {
|
|
129
|
+
remote.keepers = keeper ? [keeper] : [];
|
|
130
|
+
remote.aclOwner = ownerWebId || remote.aclOwner || null;
|
|
131
|
+
await provisionForum(remote, plain, { moderatorWebIds: config.moderatorWebIds || [] });
|
|
132
|
+
await restateRules(remote, plain);
|
|
133
|
+
for (const c of config.categories || []) {
|
|
134
|
+
const urls = plain.category(c.slug);
|
|
135
|
+
const members = (config.membersOnly || []).includes(c.slug) ? (config.memberWebIds?.[c.slug] || []) : null;
|
|
136
|
+
await provisionCategory(remote, urls, { memberWebIds: members });
|
|
137
|
+
await restateRules(remote, urls);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A forum made at a Gateway's sign-up page, start to finish: config and
|
|
143
|
+
* containers on the pod, its rows at the Gateway, the Gateway named as its
|
|
144
|
+
* keeper in every rule, and the keeper's first run started by the last
|
|
145
|
+
* switch. `remote` is the owner's pod transport, `fetch` their pod session's
|
|
146
|
+
* fetch (the Gateway proves the owner by it). Returns the forum's handle and
|
|
147
|
+
* the Gateway it lives at.
|
|
148
|
+
*/
|
|
149
|
+
export async function setUpForumAtGateway({ remote, storageFor, fetch: f, front, ownerWebId, ...opts }, { log = () => {}, onStep = () => {} } = {}) {
|
|
150
|
+
// `onStep(key, state)`: write, attach, keep; running then ok.
|
|
151
|
+
onStep('write', 'running');
|
|
152
|
+
const { storage, config, plain } = await writeForumConfig(remote, storageFor, opts, { log });
|
|
153
|
+
onStep('write', 'ok');
|
|
154
|
+
onStep('attach', 'running');
|
|
155
|
+
const { gateway, handles } = await attachRows({ fetch: f, front, config, plain }, { log });
|
|
156
|
+
await writeConfig(storage, { ...config, gateway, republish: true });
|
|
157
|
+
onStep('attach', 'ok');
|
|
158
|
+
onStep('keep', 'running');
|
|
159
|
+
const { keeper } = await keepRows({ fetch: f, front, config, on: true });
|
|
160
|
+
await nameKeeperInRules(remote, { config, plain, keeper, ownerWebId });
|
|
161
|
+
onStep('keep', 'ok');
|
|
162
|
+
return { handle: config.handle, front: gateway.front, handles, keeper };
|
|
163
|
+
}
|