fedipod-bb 0.4.3 → 0.5.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.
@@ -490,14 +490,21 @@ export class ForumAgent {
490
490
  // A pin that holds across the whole forum, not just one category: the site
491
491
  // actor has a featured collection of its own, and this is it.
492
492
  async sitePin(topicId, on) {
493
- const { orderedCollection } = await import('fedipod/core/wire.mjs');
494
- const collection = await import('fedipod/pod/collection.mjs');
495
493
  const held = this.store.read('sitepins.json', []).filter(id => id !== topicId);
496
494
  const ids = on ? [topicId, ...held] : held;
497
495
  this.store.write('sitepins.json', ids);
496
+ await this.publishSitePins();
497
+ return { pinned: ids };
498
+ }
499
+
500
+ // The forum-wide pinned topics, from the forum's own record.
501
+ async publishSitePins() {
502
+ const { orderedCollection } = await import('fedipod/core/wire.mjs');
503
+ const collection = await import('fedipod/pod/collection.mjs');
504
+ const ids = this.store.read('sitepins.json', []);
498
505
  await collection.writeFlat(this.remote, this.site.featured,
499
506
  orderedCollection(this.site.featured, ids), { publicRead: true });
500
- return { pinned: ids };
507
+ return ids;
501
508
  }
502
509
 
503
510
  // A post from someone who is not a member. 'members' drops it, which is
@@ -710,6 +717,52 @@ export class ForumAgent {
710
717
  }
711
718
  }
712
719
 
720
+ // Copies a pod should hold and does not, as on a pod the forum has just
721
+ // moved to: each post's readable copy read again from where its author
722
+ // keeps it, with its counts from the forum's own record, and each author's
723
+ // card. A copy the pod will not say anything about is left alone. Returns
724
+ // how many posts were copied again.
725
+ async refillCopies() {
726
+ let filled = 0;
727
+ const missing = async (url) => { try { return (await this.remote.getJson(url)) === null; } catch { return false; } };
728
+ for (const cat of this.categories) {
729
+ const votes = cat.store.read('votes.json', {});
730
+ for (const entry of topics.list(cat.store)) {
731
+ const doc = topics.get(cat.store, entry.tid);
732
+ for (const p of doc?.posts || []) {
733
+ if (!await missing(cat.urls.cached(p.id))) continue;
734
+ const note = await cat.intake.fetchAP(p.id).catch(() => null) || await this.goneAt(p.id);
735
+ // Taken down by its author: the copy says so, as the forum's own does
736
+ // when the author's Delete arrives.
737
+ if (note?.type === 'Tombstone') {
738
+ await publish.tombstoneCached(cat, p.id, { formerType: note.formerType || 'Note' });
739
+ filled += 1;
740
+ continue;
741
+ }
742
+ if (!note?.id) { this.log(`${p.id}: no copy here, and its author's server did not give it`); continue; }
743
+ const v = votes[p.id];
744
+ await publish.cachePost(cat, note, { topic: cat.urls.topic(entry.tid),
745
+ replies: doc.posts.filter(x => x.inReplyTo === p.id).length,
746
+ likes: Array.isArray(v) ? v.length : (v?.up || []).length, dislikes: Array.isArray(v) ? 0 : (v?.down || []).length });
747
+ filled += 1;
748
+ if (p.author && await missing(cat.urls.cached(p.author))) {
749
+ const card = await cat.intake.fetchAP(p.author).catch(() => null);
750
+ if (card?.id) await publish.cacheAuthor(cat, card).catch(e => this.log(`author card for ${p.author}: ${e.message}`));
751
+ }
752
+ }
753
+ }
754
+ }
755
+ return filled;
756
+ }
757
+
758
+ // A post its author's server says is gone (410), as the Tombstone it is.
759
+ async goneAt(url) {
760
+ const r = await (this.probeFetch || globalThis.fetch)(url, { headers: { accept: 'application/activity+json' } }).catch(() => null);
761
+ if (r?.status !== 410) return null;
762
+ const body = await r.json().catch(() => ({}));
763
+ return { type: 'Tombstone', formerType: body?.formerType || 'Note' };
764
+ }
765
+
713
766
  rebuildLatest() {
714
767
  const rows = [];
715
768
  for (const cat of this.categories) {
@@ -807,7 +860,12 @@ export class ForumAgent {
807
860
  // that publish is forgotten here and the publish below writes it.
808
861
  if (redo) this.store.write('published.json', { ...this.store.read('published.json', {}), categories: null, latest: null, administrators: null });
809
862
  const seen = this.store.read('published.json', {});
810
- if (force || !seen.actorDigest) await this.siteAgent.publisher.publishProfile({ force });
863
+ if (force || !seen.actorDigest) {
864
+ const profile = await this.siteAgent.publisher.publishProfile({ force });
865
+ // The profile publish wrote the forum's featured list as its pinned
866
+ // posts, of which a forum has none: the forum-wide pinned topics go back.
867
+ if (!profile?.skipped) await this.publishSitePins();
868
+ }
811
869
  await publish.publishCategories(this.siteAgent, this.categories.map(c => c.urls.actor), { force });
812
870
  if (!this.store.read('latest.json', []).length) {
813
871
  const n = this.rebuildLatest();
package/src/gateway.mjs CHANGED
@@ -17,6 +17,9 @@ import { keeperSession } from 'fedipod/gateway/keeper-session.mjs';
17
17
  import { ForumAgent } from './forum-agent.mjs';
18
18
  import * as publish from './publish.mjs';
19
19
 
20
+ // The forum's page at its own host, drawn for the gateway to send.
21
+ export { renderPage } from './render.mjs';
22
+
20
23
  // How soon a run comes back for an ask that could not be applied yet.
21
24
  const ASK_RETRY_MS = 60_000;
22
25
 
@@ -195,7 +198,23 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
195
198
  prepare?.(forum); // a test's origins, in place of the network
196
199
  if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
197
200
  try {
201
+ // A forum on a pod it has not published to — moved there, its rows
202
+ // naming the new pod — is published there whole, and what that pod
203
+ // lacks is read again from where its authors keep it. Recorded once
204
+ // done, so it is done once.
205
+ const moved = forum.store.read('home.json', {}).home !== f.home;
206
+ if (moved) {
207
+ forum.store.setConfig({ ...forum.store.getConfig(), republish: true });
208
+ forum.config = forum.store.getConfig();
209
+ }
198
210
  await forum.publishAll();
211
+ if (moved) {
212
+ const n = await forum.refillCopies().catch((e) => { say(`copying again: ${e.message}`); return null; });
213
+ if (n !== null) {
214
+ forum.store.write('home.json', { home: f.home, at: new Date().toISOString() });
215
+ say(`published whole at ${f.home}${n ? `; ${n} post(s) copied again from their authors` : ''}`);
216
+ }
217
+ }
199
218
  await forum.intake.drain();
200
219
  await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
201
220
  await forum.publishModQueue().catch((e) => say(`queue: ${e.message}`));
package/src/render.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // render.mjs — a forum's page at its own host, drawn on the server: the
2
+ // front page, a category or a topic, read as a stranger reads it and drawn
3
+ // with the page's own drawing, with what it was drawn from in the page so the
4
+ // browser reads none of it again. Anything that does not come out whole is
5
+ // nothing, and the caller sends the page undrawn for the browser to build.
6
+
7
+ import { parseDocument } from 'htmlparser2';
8
+ import { reader, placeOf, viewOfPath } from '../site/read.mjs';
9
+ import { NOBODY, addresses, esc, modNames, modsWho, readIndex, frontRows, rowFacts, moderatorsOf,
10
+ drawFront, readTopic, drawTopic } from '../site/draw.mjs';
11
+
12
+ // The page cleans a post with the browser's parser; here htmlparser2's tree
13
+ // stands in, read through the same few DOM properties the cleaner uses.
14
+ const node = (n) => (n.type === 'text' ? { nodeType: 3, nodeValue: n.data }
15
+ : ['tag', 'script', 'style'].includes(n.type)
16
+ ? { nodeType: 1, nodeName: n.name.toUpperCase(),
17
+ attributes: Object.entries(n.attribs).map(([name, value]) => ({ name, value })),
18
+ get childNodes() { return n.children.map(node); } }
19
+ : { nodeType: 8 });
20
+ export const parse = (html) => ({ childNodes: parseDocument(html).children.map(node) });
21
+
22
+ // How many documents are asked for at once, and how long a reader waits for
23
+ // the drawing before the page goes out undrawn instead.
24
+ const AT = 8;
25
+ const DEADLINE_MS = 2000;
26
+ // Enough posts to fill a page of topics, as the page asks for.
27
+ const FRONT_POSTS = 150;
28
+
29
+ // The page with the drawing in place of its empty parts. Every part it fills
30
+ // must be where it was, or the page is not drawn at all.
31
+ function fill(shell, { title, description, forumName, forumHref, modsLine, acts, trail, main, data }) {
32
+ const swaps = [
33
+ [/<title>[^<]*<\/title>/u, `<title>${esc(title)}</title>${description ? `\n<meta name="description" content="${esc(description)}">` : ''}`],
34
+ [/<a id="forum-link" href="[^"]*">[^<]*<\/a>/u, `<a id="forum-link" href="${esc(forumHref)}">${esc(forumName)}</a>`],
35
+ [/<span id="mods-who"><\/span>/u, `<span id="mods-who">${modsLine}</span>`],
36
+ [/<span id="mods-acts"><\/span>/u, `<span id="mods-acts">${acts}</span>`],
37
+ [/<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"><\/nav>/u,
38
+ `<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"${trail.hidden ? ' hidden' : ''}>${trail.html}</nav>`],
39
+ [/<main id="main" tabindex="-1">[\s\S]*?<\/main>/u, `<main id="main" tabindex="-1">\n${main}\n</main>`],
40
+ // What it was drawn from, for the page: JSON, with nothing in it that can
41
+ // end the element it sits in.
42
+ [/<script type="module"/u, `<script type="application/json" id="bb-data">${JSON.stringify(data).replace(/</gu, '\\u003c')}</script>\n<script type="module"`],
43
+ ];
44
+ let out = shell;
45
+ for (const [at, to] of swaps) {
46
+ if (!at.test(out)) return null;
47
+ out = out.replace(at, () => to);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ async function draw(url, shell, f) {
53
+ const place = placeOf({ origin: url.origin, pathname: url.pathname, search: '' });
54
+ if (!place.own || !place.root || !place.base) return null;
55
+ const view = viewOfPath(url.pathname, place.root);
56
+ const read = reader({ fetch: f, at: AT });
57
+ const forum = await read.forum(place.base);
58
+ if (!forum) return null;
59
+ const cats = forum.categories;
60
+ const addr = addresses(place.root);
61
+ const frontHost = new URL(place.front).host;
62
+ const handleOf = (cat) => (cat.slug ? `@${cat.slug}@${frontHost}` : cat.id);
63
+ const d = { viewer: NOBODY, addr, handleOf, parse };
64
+ const cards = new Map();
65
+ const authorOf = (cbase, id) => {
66
+ const key = `${cbase}|${id}`;
67
+ if (!cards.has(key)) cards.set(key, read.author(cbase, id).catch(() => null));
68
+ return cards.get(key);
69
+ };
70
+ // The edge keeps the page under the forum's and every category's names,
71
+ // the same names their documents carry, so what drops those drops this.
72
+ const tags = [`u-${place.handle}`, ...cats.map(c => c.slug).filter(Boolean).map(s => `u-${s}`)];
73
+ const data = { v: 1, view, forum };
74
+ let drawn;
75
+ let status = 200;
76
+ let names;
77
+ if (view.kind === 'topic') {
78
+ const cat = cats.find(c => c.slug === view.slug || c.base.endsWith('/c/' + view.slug + '/'));
79
+ if (!cat) {
80
+ status = 404;
81
+ drawn = { main: '<p class="err">No such category.</p>', crumbs: { html: '', hidden: true }, title: forum.name || 'FediPod-BB', description: '', acts: '' };
82
+ } else {
83
+ const got = await readTopic(read, cat, view.tid, AT);
84
+ if (!got.t) status = 404;
85
+ drawn = { acts: '', ...drawTopic({ cat, slug: view.slug, ...got }, d) };
86
+ data.topic = got;
87
+ }
88
+ // A topic shows who runs the forum, as the page does when it opens there.
89
+ names = (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
90
+ } else {
91
+ const filter = view.kind === 'cat' ? view.slug : 'all';
92
+ const idx = await readIndex(read, place.base, cats, FRONT_POSTS, AT);
93
+ const rows = frontRows({ cats, idx, filter });
94
+ const facts = await rowFacts(rows.page, cats, authorOf);
95
+ let membersOnly = false;
96
+ if (!rows.topics.length && rows.here) membersOnly = !(await read.topics(rows.here.base)).total && !(await read.canRead(rows.here.base));
97
+ if (view.kind === 'cat' && !rows.here) status = 404;
98
+ drawn = drawFront({ forum, cats, filter, rows, facts, membersOnly, front: place.front }, d);
99
+ const mods = moderatorsOf(idx, rows.here);
100
+ names = mods.length ? await modNames(read, cats, mods, authorOf, AT) : (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
101
+ data.index = idx;
102
+ data.facts = { authors: Object.fromEntries(await Promise.all([...cards].map(async ([k, p]) => [k, await p]))),
103
+ digests: Object.fromEntries(rows.page.map((p, i) => [p.id, facts[i].digest])) };
104
+ }
105
+ data.names = names;
106
+ const html = fill(shell, {
107
+ title: drawn.title, description: drawn.description, forumName: forum.name || 'Forum', forumHref: addr.front(),
108
+ modsLine: modsWho(names), acts: drawn.acts || '', trail: drawn.crumbs, main: drawn.main, data,
109
+ });
110
+ return html ? { status, html, tags } : null;
111
+ }
112
+
113
+ /**
114
+ * The page for `url` at a forum's own host (`https://bb.<gateway>/<forum>/…`),
115
+ * drawn from `shell` (the page as the site serves it), reading the forum
116
+ * through `fetch`. Returns { status, html, tags } or null: null means send
117
+ * the page undrawn. Never throws. A drawing that is not done in time goes on
118
+ * reading after the page has gone, when `keepGoing(promise)` lets it (the
119
+ * host's waitUntil): what it reads is then at hand for the next reader.
120
+ */
121
+ export async function renderPage({ url, shell, fetch: f = globalThis.fetch.bind(globalThis), deadlineMs = DEADLINE_MS, keepGoing = null }) {
122
+ let u;
123
+ try { u = new URL(url); } catch { return null; }
124
+ if (u.search || !shell) return null;
125
+ const work = draw(u, shell, f).catch(() => null);
126
+ let timer;
127
+ const LATE = Symbol('late');
128
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(LATE), deadlineMs); });
129
+ try {
130
+ const out = await Promise.race([work, late]);
131
+ if (out !== LATE) return out;
132
+ try { keepGoing?.(work); } catch { /* the host would not wait */ }
133
+ return null;
134
+ } finally { clearTimeout(timer); }
135
+ }