fedipod-bb 0.4.0 → 0.4.2

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 CHANGED
@@ -12,11 +12,10 @@ the forum from a moderator's machine, and the page that shows the forum.
12
12
  A moderator's console is part of that page: the queue of reports, held posts
13
13
  and asks, and the settings.
14
14
  The queue shows what needs a moderator: a report, a held post, a join
15
- request, and a request of a moderator's that did not take. The design and the phases are in
16
- `claude/plans/fedipod-bb.md` at the repository root.
15
+ request, and a request of a moderator's that did not take.
17
16
 
18
17
  ```
19
- node --test packages/fedipod-bb/test/*.test.mjs
18
+ npm test
20
19
  ```
21
20
 
22
21
  ## Hosting a forum
@@ -181,7 +180,7 @@ category's address; a reader with nothing is pointed at sign-up.
181
180
  ## Attaching to a Gateway
182
181
 
183
182
  ```
184
- node packages/fedipod-bb/bin/fedipod-bb.mjs attach --home DIR --front https://fedipod.net
183
+ fedipod-bb attach --home DIR --front https://fedipod.net
185
184
  ```
186
185
 
187
186
  Takes `@<handle>@fedipod.net` for the forum and one address per category,
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "fedipod-bb",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
5
5
  "license": "MIT",
6
+ "author": "Jeff Zucker",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jeff-zucker/fedipod-bb.git"
10
+ },
11
+ "homepage": "https://github.com/jeff-zucker/fedipod-bb#readme",
12
+ "bugs": "https://github.com/jeff-zucker/fedipod-bb/issues",
6
13
  "type": "module",
7
14
  "main": "src/index.mjs",
8
15
  "exports": {
@@ -23,11 +30,10 @@
23
30
  "fep-draft.md"
24
31
  ],
25
32
  "scripts": {
26
- "pretest": "node scripts/link-fedipod.mjs",
27
33
  "test": "node --test test/*.test.mjs"
28
34
  },
29
35
  "dependencies": {
30
- "fedipod": "^1.44.0",
36
+ "fedipod": "^1.50.2",
31
37
  "fediverse-session": "^0.2.1"
32
38
  }
33
39
  }
package/site/bb.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // https://bb.<site>/<handle>/ the same forum at its own address
11
11
  // #/ #/c/<slug> #/t/<slug>/<tid>
12
12
 
13
- import { reader, placeOf, authorLabel, cacheKey, categoryBase } from './read.mjs';
13
+ import { reader, placeOf, authorLabel, cacheKey, categoryBase, cleanHtml } from './read.mjs';
14
14
  import { MastoLogin, hostOfHandle, serverKind, actorOfHandle } from './masto.mjs';
15
15
  import * as pod from './pod.mjs';
16
16
  import { mine as keptByReader } from './mine.mjs';
@@ -70,7 +70,7 @@ const handleOf = (cat) => {
70
70
  // A post names the category it is for, and every reader of this page is
71
71
  // already in it: the address is how it got here, not something to read.
72
72
  function body(p, cat) {
73
- const html = p?.content || '';
73
+ const html = cleanHtml(p?.content);
74
74
  if (!cat) return html;
75
75
  const d = document.createElement('div');
76
76
  d.innerHTML = html;
@@ -297,7 +297,7 @@ async function showReplies() {
297
297
  const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
298
298
  items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
299
299
  <div class="meta">${who ? esc(who.handle) : esc(authorLabel(p.author || ''))} · ${esc(when(p.published))}</div>
300
- <article class="post"><div class="body">${p.content || ''}</div></article></li>`);
300
+ <article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
301
301
  }
302
302
  $('main').innerHTML = `<ul class="list">${items.join('')}</ul>`;
303
303
  }
@@ -391,7 +391,7 @@ async function showWho(handleOrId) {
391
391
  const href = c && tid ? `#/t/${esc(c.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
392
392
  items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
393
393
  <div class="meta">${c ? esc(c.name) + ' · ' : ''}${esc(when(p.published))} · ▲ ${p.likes || 0} ▼ ${p.dislikes || 0}</div>
394
- <article class="post"><div class="body">${p.content || ''}</div></article></li>`);
394
+ <article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
395
395
  }
396
396
  $('main').innerHTML = head + `<ul class="list">${items.join('')}</ul>`;
397
397
  }
package/site/read.mjs CHANGED
@@ -10,6 +10,51 @@ const MAX_PAGES = 50;
10
10
 
11
11
  export const categoryBase = (actorId) => String(actorId).replace(/ap\/actor$/u, '');
12
12
 
13
+ // A post's text, one copy: a server that sends it once per language hands
14
+ // over a list, read as its first.
15
+ export function textOf(v) {
16
+ if (typeof v === 'string') return v;
17
+ if (Array.isArray(v)) return v.find(s => typeof s === 'string') || '';
18
+ return '';
19
+ }
20
+
21
+ // A post's text as the page shows it: only the tags and attributes a post may
22
+ // carry (the list the forum's host keeps when it stores one), built back up
23
+ // from a parse so nothing else reaches the page. DOMParser's parse is inert:
24
+ // it runs no script and loads no image. `parse` is for tests.
25
+ const KEEP = new Set(['p', 'br', 'a', 'span', 'em', 'strong', 'b', 'i', 'u', 'del',
26
+ 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4']);
27
+ const ATTRS = new Set(['href', 'class', 'lang', 'title']);
28
+ const DROP = new Set(['script', 'style', 'textarea', 'noscript', 'template', 'title', 'head']);
29
+ const escText = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
30
+ const linkable = (href) => { try { return ['http:', 'https:', 'mailto:'].includes(new URL(href).protocol); } catch { return false; } };
31
+ const inert = (html) => new DOMParser().parseFromString(html, 'text/html').body;
32
+
33
+ function rebuild(node) {
34
+ let out = '';
35
+ for (const n of node.childNodes) {
36
+ if (n.nodeType === 3) { out += escText(n.nodeValue); continue; }
37
+ if (n.nodeType !== 1) continue;
38
+ const tag = n.nodeName.toLowerCase();
39
+ if (DROP.has(tag)) continue;
40
+ if (!KEEP.has(tag)) { out += rebuild(n); continue; }
41
+ let attrs = '';
42
+ for (const a of n.attributes) {
43
+ const name = a.name.toLowerCase();
44
+ if (!ATTRS.has(name) || (name === 'href' && (tag !== 'a' || !linkable(a.value)))) continue;
45
+ attrs += ` ${name}="${escText(a.value)}"`;
46
+ }
47
+ if (tag === 'a') attrs += ' rel="nofollow noopener noreferrer"';
48
+ out += tag === 'br' ? '<br>' : `<${tag}${attrs}>${rebuild(n)}</${tag}>`;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function cleanHtml(html, parse = inert) {
54
+ const text = textOf(html);
55
+ return text ? rebuild(parse(text)) : '';
56
+ }
57
+
13
58
  // Where a forum is read from: through a Gateway at `<origin>/u/<handle>/`, or
14
59
  // a pod home given outright.
15
60
  export const forumBase = ({ origin = null, handle = null, pod = null } = {}) => {
@@ -213,8 +258,8 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
213
258
  id: copy.id || url,
214
259
 
215
260
  author: idOf([].concat(copy.attributedTo || [])[0]) || null,
216
- name: copy.name || null,
217
- content: typeof copy.content === 'string' ? copy.content : '',
261
+ name: textOf(copy.name) || null,
262
+ content: textOf(copy.content),
218
263
  published: copy.published || null,
219
264
  topic: idOf(copy.context) || null,
220
265
  inReplyTo: idOf(copy.inReplyTo) || null,
@@ -278,7 +323,7 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
278
323
  return {
279
324
  id: copy.id || postId, type: copy.type || 'Note', gone: copy.type === 'Tombstone', page,
280
325
  author: idOf([].concat(copy.attributedTo || [])[0]) || null,
281
- name: copy.name || null, content: typeof copy.content === 'string' ? copy.content : '',
326
+ name: textOf(copy.name) || null, content: textOf(copy.content),
282
327
  // What its author typed, when they said so: an edit reopens this
283
328
  // rather than guessing it back out of the HTML.
284
329
  source: copy.source?.mediaType === 'text/markdown' && typeof copy.source?.content === 'string' ? copy.source.content : null,
package/src/gateway.mjs CHANGED
@@ -27,7 +27,9 @@ export const isForumRow = (rec) => !!rec?.inboxUrl && (rec.kind === 'group' || r
27
27
  export async function forumFor(ctx, handle, rec) {
28
28
  const forumHandle = rec.kind === 'application' ? handle : rec.forum;
29
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);
30
+ // Read fresh where the front offers it: the forum's row is switched on
31
+ // last, and its first run starts in the same breath.
32
+ const forumRec = forumHandle === handle ? rec : await (ctx.lookupFresh || ctx.lookup)(forumHandle);
31
33
  if (!forumRec || !isForumRow(forumRec) || !forumRec.keeper) return { skipped: `@${forumHandle} is not a forum this gateway keeps` };
32
34
  if (!forumRec.webId || !forumRec.podHome) return { skipped: 'the forum row names no owner or pod' };
33
35
  const home = forumRec.podHome.replace(/\/?$/u, '/');
@@ -89,7 +91,7 @@ async function readConfig(ctx, f, podFetch) {
89
91
  export async function ensureForumCopies(ctx, f, config, log) {
90
92
  const rows = [[f.forumHandle, f.forumRec]];
91
93
  for (const c of config?.categories || []) {
92
- const r = await ctx.lookup(c.slug);
94
+ const r = await (ctx.lookupFresh || ctx.lookup)(c.slug);
93
95
  if (r && isForumRow(r)) rows.push([c.slug, r]);
94
96
  }
95
97
  const have = new Set();
@@ -139,6 +141,33 @@ function nextRun(forum, asksPending) {
139
141
  * Returns what the keeper's bookkeeping expects: { drained, waiting, nextAt }
140
142
  * or { skipped, retry }.
141
143
  */
144
+ // A queued delivery's identity: where it goes and what it carries.
145
+ const queueKey = (item) => `${item.inbox}\u0000${typeof item.activity === 'string' ? item.activity : JSON.stringify(item.activity)}`;
146
+
147
+ /**
148
+ * The queue after a carry that ran with nobody holding the forum: `before`
149
+ * is what the run took out to carry, `after` what the carry left of it
150
+ * (delivered items gone, failed ones with their next time), `fresh` the
151
+ * queue as the copy holds it now, which the door may have added to in the
152
+ * meantime. A door only ever appends, so: an item the run took out is kept
153
+ * as the carry left it or dropped if delivered; anything else is the door's
154
+ * and stays.
155
+ */
156
+ export function mergeQueues(before, after, fresh) {
157
+ const taken = new Set(before.map(queueKey));
158
+ const left = new Map(after.map((i) => [queueKey(i), i]));
159
+ const out = [];
160
+ for (const item of fresh) {
161
+ const k = queueKey(item);
162
+ if (!taken.has(k)) { out.push(item); continue; }
163
+ if (left.has(k)) out.push(left.get(k));
164
+ }
165
+ return out;
166
+ }
167
+
168
+ const storesOf = (forum) => [forum.store, ...forum.categories.map((c) => c.store)];
169
+ const deliverersOf = (forum) => [...forum.categories.map((c) => c.deliverer), forum.siteAgent.deliverer];
170
+
142
171
  export async function keepOnce(ctx, handle, rec, { log = console.log, session = null, remote: given = null, prepare = null } = {}) {
143
172
  const f = await forumFor(ctx, handle, rec);
144
173
  if (f.skipped) return { skipped: f.skipped };
@@ -149,10 +178,19 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
149
178
  const config = await readConfig(ctx, f, podFetch);
150
179
  if (!config) return { skipped: 'no forum on the pod' };
151
180
  const copies = await ensureForumCopies(ctx, f, config, say);
181
+ // The forum is held only while its state changes: the drain, the asks, the
182
+ // queue, the heartbeat, the write to the copies. The carry — one signed
183
+ // POST per follower server, each with its own timeout — runs with the forum
184
+ // free, so a post arriving at the door meanwhile is placed at once instead
185
+ // of waiting for the run to end. The queue the carry changes is written
186
+ // back under the lock, merged with whatever the door queued in between.
152
187
  const unlock = await lockCopy(ctx.copyKv, f.forumHandle);
153
188
  if (!unlock) return { skipped: 'the gateway is busy with this forum', retry: 'soon' };
189
+ let forum;
190
+ let before;
191
+ let asks = 0;
154
192
  try {
155
- const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
193
+ forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
156
194
  if (!await forum.connect({ act: false })) return { skipped: 'no forum on the pod' };
157
195
  prepare?.(forum); // a test's origins, in place of the network
158
196
  if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
@@ -161,22 +199,42 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
161
199
  await forum.intake.drain();
162
200
  await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
163
201
  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
202
  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) };
203
+ await Promise.allSettled(storesOf(forum).map((s) => s.flush()));
204
+ asks = storesOf(forum).reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
205
+ before = new Map(storesOf(forum).map((s) => [s, s.getQueue?.() || []]));
178
206
  } finally { await forum.lease.release().catch(() => {}); }
179
207
  } finally { await unlock(); }
208
+
209
+ // The carry, with the forum free.
210
+ for (const d of deliverersOf(forum)) await d.drainQueue().catch((e) => say(`carry: ${e.message}`));
211
+ const after = new Map(storesOf(forum).map((s) => [s, s.getQueue?.() || []]));
212
+
213
+ // What the carry left, written back under the lock beside what the door
214
+ // queued meanwhile. The door holds the forum for seconds, so the wait is short.
215
+ const relock = await lockCopy(ctx.copyKv, f.forumHandle, { waitMs: 20_000 });
216
+ if (!relock) { say('the queue could not be written back: the forum stayed busy'); return { drained: true, waiting: null, retry: 'soon' }; }
217
+ let waiting = 0;
218
+ try {
219
+ // The copy takes a write only from the lease's holder; the lease again, briefly.
220
+ if (!await forum.lease.acquire()) { say('the queue could not be written back: another host has the forum'); return { drained: true, waiting: null, retry: 'soon' }; }
221
+ for (const s of storesOf(forum)) {
222
+ let fresh = null;
223
+ try { await s.load(); fresh = s.getQueue?.() || []; } catch { fresh = null; }
224
+ const merged = fresh ? mergeQueues(before.get(s) || [], after.get(s) || [], fresh) : (after.get(s) || []);
225
+ s.setQueue?.(merged);
226
+ waiting += merged.length;
227
+ }
228
+ await Promise.allSettled(storesOf(forum).map((s) => s.flush()));
229
+ } finally { await forum.lease.release().catch(() => {}); await relock(); }
230
+ // The run wrote on the pod (what the door could not place, the queue, the
231
+ // heartbeat): the edge's copies of the forum's documents go.
232
+ if (ctx.purge) {
233
+ const tags = [`u-${f.forumHandle}`, ...(config.categories || []).map((c) => `u-${c.slug}`)];
234
+ await ctx.purge(tags).catch((e) => say(`purge: ${e.message}`));
235
+ }
236
+ say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
237
+ return { drained: true, waiting, nextAt: nextRun(forum, asks) };
180
238
  }
181
239
 
182
240
  /**
package/src/keep.mjs CHANGED
@@ -12,11 +12,19 @@ import { keepRows } from './setup.mjs';
12
12
  // does, from the pod's own rules, which name it beside the owner from here
13
13
  // on. `on: false` takes the name out again.
14
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();
15
+ // Handing over: the categories' rows, then every rule on the tree with the
16
+ // keeper's name, then the forum's own row — whose switch starts the
17
+ // Gateway's first run, which reads the pod under those rules. Taking it
18
+ // back: the rows, then the rules without the name.
19
+ const restate = async (keeper) => {
20
+ forum.keepers = on && keeper ? [keeper] : [];
21
+ forum.remote.keepers = [...forum.keepers];
22
+ forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
23
+ await forum.restateRules();
24
+ };
25
+ const { keeper, handles } = await keepRows({ fetch: (u, i) => forum.remote.session.fetch(u, i), front, config: forum.config, on,
26
+ between: on ? restate : null });
27
+ if (!on) await restate(null);
20
28
  return { keeper: on ? keeper : null, handles, on };
21
29
  }
22
30
 
package/src/publish.mjs CHANGED
@@ -175,6 +175,8 @@ export async function cachePost({ remote, urls }, note, { topic = null, replies
175
175
  // count beside every post it shows.
176
176
  if (Number.isFinite(likes)) copy.likes = { type: 'Collection', totalItems: likes };
177
177
  if (Number.isFinite(dislikes)) copy.dislikes = { type: 'Collection', totalItems: dislikes };
178
+ // One copy of the text: a server that sends it once per language hands over a list.
179
+ if (Array.isArray(copy.content)) copy.content = copy.content.find(s => typeof s === 'string') || '';
178
180
  if (typeof copy.content === 'string') copy.content = sanitizeHtml(copy.content);
179
181
  if (!copy['@context']) copy['@context'] = 'https://www.w3.org/ns/activitystreams';
180
182
  const url = urls.cached(note.id);
package/src/setup.mjs CHANGED
@@ -99,15 +99,18 @@ export async function attachRows({ fetch: f, front, config, plain }, { log = ()
99
99
  /**
100
100
  * Let a Gateway keep the forum (or stop): every row, the categories first and
101
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.
102
+ * its first run, and that run wants every category kept already. `between`,
103
+ * given the keeper's WebID, runs after the categories and before that last
104
+ * switch: the caller names the keeper in the pod's rules there, so the first
105
+ * run can read the pod the moment it starts. Returns the keeper's WebID.
104
106
  */
105
- export async function keepRows({ fetch: f, front, config, on = true }) {
107
+ export async function keepRows({ fetch: f, front, config, on = true, between = null }) {
106
108
  const origin = String(front).replace(/\/+$/u, '');
107
109
  const forumHandle = config.handle;
108
110
  const handles = [...(config.categories || []).map(c => c.slug), forumHandle];
109
111
  let keeper = null;
110
112
  for (const handle of handles) {
113
+ if (handle === forumHandle && between) await between(keeper);
111
114
  const res = await f(`${origin}/api/keeper`, {
112
115
  method: 'POST', headers: { 'content-type': 'application/json' },
113
116
  body: JSON.stringify({ handle, on, ...(handle === forumHandle ? {} : { forum: forumHandle }) }),
@@ -156,8 +159,8 @@ export async function setUpForumAtGateway({ remote, storageFor, fetch: f, front,
156
159
  await writeConfig(storage, { ...config, gateway, republish: true });
157
160
  onStep('attach', 'ok');
158
161
  onStep('keep', 'running');
159
- const { keeper } = await keepRows({ fetch: f, front, config, on: true });
160
- await nameKeeperInRules(remote, { config, plain, keeper, ownerWebId });
162
+ const { keeper } = await keepRows({ fetch: f, front, config, on: true,
163
+ between: (k) => nameKeeperInRules(remote, { config, plain, keeper: k, ownerWebId }) });
161
164
  onStep('keep', 'ok');
162
165
  return { handle: config.handle, front: gateway.front, handles, keeper };
163
166
  }