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.
- package/README.md +6 -0
- package/package.json +3 -2
- package/site/bb.js +235 -351
- package/site/draw.mjs +370 -0
- package/site/index.html +2 -2
- package/site/pod.mjs +17 -0
- package/site/read.mjs +67 -18
- package/src/forum-agent.mjs +62 -4
- package/src/gateway.mjs +19 -0
- package/src/render.mjs +135 -0
package/src/forum-agent.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
+
}
|