fedipod-bb 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -0
- package/bin/fedipod-bb.mjs +113 -0
- package/fep-draft.md +110 -0
- package/package.json +26 -0
- package/site/bb.js +1644 -0
- package/site/index.html +301 -0
- package/site/markdown.mjs +0 -0
- package/site/masto.mjs +179 -0
- package/site/mine.mjs +48 -0
- package/site/oidc-session.mjs +6 -0
- package/site/pod.mjs +394 -0
- package/site/private.mjs +98 -0
- package/site/read.mjs +294 -0
- package/site/seen.mjs +64 -0
- package/src/access.mjs +79 -0
- package/src/credential.mjs +37 -0
- package/src/forum-agent.mjs +991 -0
- package/src/forum-intake.mjs +112 -0
- package/src/index.mjs +9 -0
- package/src/moderation.mjs +303 -0
- package/src/provision.mjs +37 -0
- package/src/publish.mjs +244 -0
- package/src/run.mjs +52 -0
- package/src/settings.mjs +186 -0
- package/src/topics.mjs +165 -0
- package/src/urls.mjs +90 -0
- package/src/wire.mjs +114 -0
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// publish.mjs — writing the forum's documents to the pod, and only the ones
|
|
2
|
+
// that changed. Every paged collection keeps the digest of each page it last
|
|
3
|
+
// wrote, as FediPod's outbox does, so a sealed page is never re-PUT and a
|
|
4
|
+
// republish of an unchanged forum writes nothing.
|
|
5
|
+
//
|
|
6
|
+
// A call takes `{ remote, store, urls }`: the pod transport, the owner-only
|
|
7
|
+
// state (a PodStore), and the category's or forum's urls.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import * as collection from 'fedipod/pod/collection.mjs';
|
|
11
|
+
import * as podNotes from 'fedipod/pod/notes.mjs';
|
|
12
|
+
import { sanitizeHtml, orderedCollection } from 'fedipod/core/wire.mjs';
|
|
13
|
+
import * as fwire from './wire.mjs';
|
|
14
|
+
import * as topics from './topics.mjs';
|
|
15
|
+
|
|
16
|
+
export const digestOf = (doc) => crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
17
|
+
|
|
18
|
+
// One topic: its pages and its head. Returns how many documents were written.
|
|
19
|
+
export async function publishTopic({ remote, store, urls }, tid, { force = false } = {}) {
|
|
20
|
+
const doc = topics.get(store, tid);
|
|
21
|
+
if (!doc) throw new Error(`publishTopic: no such topic ${tid}`);
|
|
22
|
+
const id = urls.topic(tid);
|
|
23
|
+
const { index, pages } = fwire.topicPaging(doc.posts.map(p => p.id), doc.index);
|
|
24
|
+
const before = force ? {} : (doc.pages || {});
|
|
25
|
+
const after = {};
|
|
26
|
+
let wrote = 0;
|
|
27
|
+
for (let i = 0; i < pages.length; i++) {
|
|
28
|
+
const n = i + 1;
|
|
29
|
+
const page = fwire.topicPage({ id, n, items: pages[i], pageCount: pages.length });
|
|
30
|
+
const digest = digestOf(page);
|
|
31
|
+
after[n] = digest;
|
|
32
|
+
if (before[n] === digest) continue;
|
|
33
|
+
// The topic container is public-Read; a page inherits it.
|
|
34
|
+
await collection.writePage(remote, urls.topicPage(tid, n), page);
|
|
35
|
+
wrote++;
|
|
36
|
+
}
|
|
37
|
+
for (const n of Object.keys(doc.pages || {}).map(Number).filter(n => n > pages.length)) {
|
|
38
|
+
await collection.dropPage(remote, urls.topicPage(tid, n));
|
|
39
|
+
}
|
|
40
|
+
const entry = topics.list(store).find(t => t.tid === tid);
|
|
41
|
+
const head = fwire.topicHead({
|
|
42
|
+
id, name: doc.title, category: urls.actor, total: doc.posts.length, pageCount: pages.length,
|
|
43
|
+
closed: store.read('topics.json', []).find(t => t.tid === tid)?.locked ? (doc.closedAt || new Date().toISOString()) : null,
|
|
44
|
+
published: entry?.created || doc.posts[0]?.published || null,
|
|
45
|
+
updated: doc.posts.length > 1 ? (entry?.last || null) : null,
|
|
46
|
+
});
|
|
47
|
+
const headDigest = digestOf(head);
|
|
48
|
+
if (force || headDigest !== doc.headDigest) {
|
|
49
|
+
await collection.writeHead(remote, id, head);
|
|
50
|
+
wrote++;
|
|
51
|
+
}
|
|
52
|
+
store.write(topics.topicDoc(tid), { ...doc, index, pages: after, headDigest });
|
|
53
|
+
return wrote;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The category's topic list. Oldest first in the record; the pages read
|
|
57
|
+
// newest first. Kept in `published.json` beside the group's own digests.
|
|
58
|
+
export async function publishTopicIndex({ remote, store, urls }, { force = false } = {}) {
|
|
59
|
+
const seen = store.read('published.json', {});
|
|
60
|
+
const order = topics.list(store).map(t => urls.topic(t.tid));
|
|
61
|
+
const { index, pages } = fwire.topicsPaging(order, seen.topicsIndex || []);
|
|
62
|
+
const before = force ? {} : (seen.topicsPages || {});
|
|
63
|
+
const after = {};
|
|
64
|
+
let wrote = 0;
|
|
65
|
+
for (let i = 0; i < pages.length; i++) {
|
|
66
|
+
const n = i + 1;
|
|
67
|
+
const page = fwire.topicsPage({ id: urls.topics, n, items: pages[i] });
|
|
68
|
+
const digest = digestOf(page);
|
|
69
|
+
after[n] = digest;
|
|
70
|
+
if (before[n] === digest) continue;
|
|
71
|
+
// Under ap/ directly, whose container is owner-only: the rule is set when
|
|
72
|
+
// a page is first created, as the outbox does.
|
|
73
|
+
await collection.writePage(remote, urls.topicsPage(n), page, { publicRead: !before[n] || force });
|
|
74
|
+
wrote++;
|
|
75
|
+
}
|
|
76
|
+
for (const n of Object.keys(seen.topicsPages || {}).map(Number).filter(n => n > pages.length)) {
|
|
77
|
+
await collection.dropPage(remote, urls.topicsPage(n));
|
|
78
|
+
}
|
|
79
|
+
const head = fwire.topicsHead({ id: urls.topics, category: urls.actor, total: order.length, pageCount: pages.length });
|
|
80
|
+
const headDigest = digestOf(head);
|
|
81
|
+
if (force || headDigest !== seen.topicsHead) {
|
|
82
|
+
await collection.writeHead(remote, urls.topics, head, { publicRead: !seen.topicsHead || force });
|
|
83
|
+
wrote++;
|
|
84
|
+
}
|
|
85
|
+
store.write('published.json', { ...store.read('published.json', {}), topicsPages: after, topicsIndex: index, topicsHead: headDigest });
|
|
86
|
+
return wrote;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The forum's two flat lists, each written only when it changed.
|
|
90
|
+
export async function publishCategories({ remote, store, urls }, actorIds, { force = false } = {}) {
|
|
91
|
+
return flat({ remote, store }, 'categories', urls.categories, fwire.categoriesCollection(urls.categories, actorIds), force);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// How many of the newest posts the forum keeps an index of.
|
|
95
|
+
// How many of the newest posts the forum keeps an index of. A page shows
|
|
96
|
+
// thirty at a time and asks for more; this is the whole of what it can ask
|
|
97
|
+
// for, and the topics themselves hold everything older.
|
|
98
|
+
export const LATEST_MAX = 500;
|
|
99
|
+
|
|
100
|
+
export async function publishLatest({ remote, store, urls }, { force = false } = {}) {
|
|
101
|
+
const items = store.read('latest.json', []).slice(0, LATEST_MAX).map(e => e.copy);
|
|
102
|
+
return flat({ remote, store }, 'latest', urls.latest, fwire.latestCollection(urls.latest, items), force);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function publishAdministrators({ remote, store, urls }, actorIds, { force = false } = {}) {
|
|
106
|
+
return flat({ remote, store }, 'administrators', urls.administrators, fwire.administratorsCollection(urls.administrators, actorIds), force);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The reader list of a private category, written where only those readers can
|
|
110
|
+
// read it. This is the document a member's own pod copies into the access rule
|
|
111
|
+
// on the container it writes that category's posts into: the forum cannot
|
|
112
|
+
// write rules on somebody else's pod, so it publishes who the rule must name
|
|
113
|
+
// and each member's own browser writes it.
|
|
114
|
+
//
|
|
115
|
+
// An open category has no list. If it had one it is taken down, so a category
|
|
116
|
+
// turned open cannot leave a stale one behind for a member's browser to copy.
|
|
117
|
+
export async function publishMembers(cat, readers, { force = false } = {}) {
|
|
118
|
+
const url = cat.urls.members;
|
|
119
|
+
const seen = cat.store.read('published.json', {});
|
|
120
|
+
if (!readers) {
|
|
121
|
+
if (!seen.members) return 0;
|
|
122
|
+
await cat.remote.delete(url).catch(() => {});
|
|
123
|
+
cat.store.write('published.json', { ...cat.store.read('published.json', {}), members: null });
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
const doc = fwire.membersCollection(url, readers);
|
|
127
|
+
const digest = digestOf(doc);
|
|
128
|
+
if (!force && seen.members === digest) return 0;
|
|
129
|
+
await cat.remote.putJson(url, doc);
|
|
130
|
+
// Not public, and not owner-only either: the people named are the people who
|
|
131
|
+
// may read it.
|
|
132
|
+
await cat.remote.setAcl(url, [], { readAgents: readers });
|
|
133
|
+
cat.store.write('published.json', { ...cat.store.read('published.json', {}), members: digest });
|
|
134
|
+
return readers.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `force` republishes the document; the rule beside it is written once, when
|
|
138
|
+
// the document first is. Forcing used to rewrite the rule too, so every post,
|
|
139
|
+
// edit and vote was two writes where one would do.
|
|
140
|
+
async function flat({ remote, store }, key, url, doc, force) {
|
|
141
|
+
const seen = store.read('published.json', {});
|
|
142
|
+
const digest = digestOf(doc);
|
|
143
|
+
if (!force && seen[key] === digest) return 0;
|
|
144
|
+
await collection.writeFlat(remote, url, doc, { publicRead: !seen[key] });
|
|
145
|
+
store.write('published.json', { ...store.read('published.json', {}), [key]: digest });
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The category's pinned topics, as its featured collection. The category's
|
|
150
|
+
// own publisher writes a featured list too, of its pinned POSTS, which a
|
|
151
|
+
// forum has none of: a forced profile publish wrote that empty list over the
|
|
152
|
+
// pinned topics. This one is written after it, from the topics' flags.
|
|
153
|
+
export async function publishPinnedTopics(cat) {
|
|
154
|
+
const ids = topics.list(cat.store).filter(t => t.pinned).map(t => cat.urls.topic(t.tid));
|
|
155
|
+
await collection.writeFlat(cat.remote, cat.urls.featured, orderedCollection(cat.urls.featured, ids), { publicRead: true });
|
|
156
|
+
return ids;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A member's post, as verified at its origin, made readable for the website.
|
|
160
|
+
// Content is sanitised again on the way in; nothing else is changed, so the
|
|
161
|
+
// copy says what the author said, under the author's own id. Returns the
|
|
162
|
+
// copy's url.
|
|
163
|
+
export async function cachePost({ remote, urls }, note, { topic = null, replies = null, likes = null, dislikes = null } = {}) {
|
|
164
|
+
const copy = { ...note };
|
|
165
|
+
if (topic) copy.context = topic;
|
|
166
|
+
if (!copy.audience) copy.audience = urls.actor;
|
|
167
|
+
// How many answered THIS post, as the forum counts them (FEP-7458 shape).
|
|
168
|
+
// The author's own replies collection is theirs and says something else:
|
|
169
|
+
// this is what the forum holds, in the topic it placed the post in.
|
|
170
|
+
if (Number.isFinite(replies)) copy.replies = { type: 'Collection', totalItems: replies };
|
|
171
|
+
// How many said they liked it (AS2 `likes`), and how many voted it down.
|
|
172
|
+
// A Dislike is an activity AS2 has; a property for a count of them is not,
|
|
173
|
+
// so `dislikes` is the forum's own word, shaped like `likes`, in the copy
|
|
174
|
+
// the forum's page reads. Read here, the page no longer asks the pod for a
|
|
175
|
+
// count beside every post it shows.
|
|
176
|
+
if (Number.isFinite(likes)) copy.likes = { type: 'Collection', totalItems: likes };
|
|
177
|
+
if (Number.isFinite(dislikes)) copy.dislikes = { type: 'Collection', totalItems: dislikes };
|
|
178
|
+
if (typeof copy.content === 'string') copy.content = sanitizeHtml(copy.content);
|
|
179
|
+
if (!copy['@context']) copy['@context'] = 'https://www.w3.org/ns/activitystreams';
|
|
180
|
+
const url = urls.cached(note.id);
|
|
181
|
+
await podNotes.write(remote, url, copy);
|
|
182
|
+
return url;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// How many voted a post DOWN. ActivityStreams has the Dislike activity and no
|
|
186
|
+
// property to publish a count of them, so none is invented here: the count is
|
|
187
|
+
// an ordinary Collection published beside the post's copy, at that copy's
|
|
188
|
+
// address with `-dislikes` after it. Only terms already in use appear in it.
|
|
189
|
+
export async function publishDislikes({ remote, urls }, postId, n) {
|
|
190
|
+
const url = urls.cached(postId) + '-dislikes';
|
|
191
|
+
if (!n) { await remote.delete(url).catch(() => {}); return 0; }
|
|
192
|
+
await podNotes.write(remote, url, {
|
|
193
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
194
|
+
id: url, type: 'Collection', totalItems: n,
|
|
195
|
+
summary: 'How many disliked the post this is named for',
|
|
196
|
+
});
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Who wrote a post, for the website: the author's actor as the host fetched
|
|
201
|
+
// it, cut down to what a reader shows — name, handle, picture — filed beside
|
|
202
|
+
// the posts under the digest of the actor's id. A FediPod actor's id does
|
|
203
|
+
// not carry its handle; this is where a browser learns it.
|
|
204
|
+
export async function cacheAuthor({ remote, urls }, actor) {
|
|
205
|
+
if (!actor?.id) return null;
|
|
206
|
+
const icon = typeof actor.icon === 'string' ? actor.icon : actor.icon?.url;
|
|
207
|
+
const card = {
|
|
208
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
209
|
+
id: actor.id, type: actor.type || 'Person',
|
|
210
|
+
...(actor.preferredUsername ? { preferredUsername: String(actor.preferredUsername).slice(0, 100) } : {}),
|
|
211
|
+
...(actor.name ? { name: String(actor.name).slice(0, 200) } : {}),
|
|
212
|
+
...(icon && /^https?:\/\//u.test(icon) ? { icon: { type: 'Image', url: String(icon).slice(0, 2048) } } : {}),
|
|
213
|
+
...(actor.url && typeof actor.url === 'string' ? { url: actor.url } : {}),
|
|
214
|
+
};
|
|
215
|
+
const url = urls.cached(actor.id);
|
|
216
|
+
await podNotes.write(remote, url, card);
|
|
217
|
+
return url;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The copy of a post that is gone: a Tombstone in its place.
|
|
221
|
+
export async function tombstoneCached({ remote, urls }, postId, { formerType = 'Note' } = {}) {
|
|
222
|
+
const url = urls.cached(postId);
|
|
223
|
+
await podNotes.write(remote, url, fwire.cachedTombstone({ id: url, formerType }));
|
|
224
|
+
return url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A public sign of life: when the forum was last hosted, and by which
|
|
228
|
+
// version. The website reads it to say how current the forum is, since the
|
|
229
|
+
// lease that really says so is owner-only.
|
|
230
|
+
const heartbeatRuled = new WeakSet(); // remotes whose heartbeat rule this process has stated
|
|
231
|
+
export async function publishHeartbeat({ remote, urls }, { version = null, at = new Date().toISOString() } = {}) {
|
|
232
|
+
// Under the advertised face, like every read document, so a fronted forum's
|
|
233
|
+
// reader finds it at the front and the transport lands it on the pod.
|
|
234
|
+
// Typed and named like the actor: a pod refuses to hand a plain-JSON
|
|
235
|
+
// document to a reader asking for ActivityPub, and the front asks that way.
|
|
236
|
+
const url = urls.actor.replace(/ap\/actor$/u, '') + 'ap/heartbeat';
|
|
237
|
+
await remote.putJson(url, { at, ...(version ? { version } : {}) }, 'application/activity+json');
|
|
238
|
+
// The rule never changes: stated once per process, and only if it differs.
|
|
239
|
+
if (!heartbeatRuled.has(remote)) {
|
|
240
|
+
await remote.setAcl(url, ['Read'], { ifChanged: true });
|
|
241
|
+
heartbeatRuled.add(remote);
|
|
242
|
+
}
|
|
243
|
+
return url;
|
|
244
|
+
}
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// run.mjs — running a forum on this machine: connect, with retries while the
|
|
2
|
+
// pod is not answering, then host or watch until the process is told to stop.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { ForumAgent } from './forum-agent.mjs';
|
|
6
|
+
|
|
7
|
+
export async function runForum({ home, log = (...a) => console.log('[bb]', ...a) } = {}) {
|
|
8
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
9
|
+
const agent = new ForumAgent({ home, log });
|
|
10
|
+
|
|
11
|
+
let up = false;
|
|
12
|
+
for (let attempt = 1; !up; attempt++) {
|
|
13
|
+
try {
|
|
14
|
+
up = await agent.connect();
|
|
15
|
+
if (!up) { log('nothing to host — run init first'); return null; }
|
|
16
|
+
} catch (e) {
|
|
17
|
+
// The attempt that just died may have taken the lease on its way in.
|
|
18
|
+
// Give it back, or the next attempt finds the forum held by a process
|
|
19
|
+
// that is this one, and waits five minutes to be told it may act. The
|
|
20
|
+
// release is a pod write too, and a pod that refused the start is
|
|
21
|
+
// usually still refusing: try it until it takes.
|
|
22
|
+
for (let i = 0; i < 4; i++) {
|
|
23
|
+
try { await agent.lease?.release(); break; } catch { await new Promise(r => { setTimeout(r, 8000); }); }
|
|
24
|
+
}
|
|
25
|
+
const wait = Math.min(15 * attempt, 120);
|
|
26
|
+
log(`start failed (${e.message}) — trying again in ${wait}s`);
|
|
27
|
+
await new Promise(r => { setTimeout(r, wait * 1000); });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Every timer in the agent is unreferenced; nothing else would hold the
|
|
32
|
+
// process open, and the host exited quietly once the push socket went
|
|
33
|
+
// idle. This holds it.
|
|
34
|
+
const hold = setInterval(() => {}, 1 << 30);
|
|
35
|
+
const shutdown = () => {
|
|
36
|
+
clearInterval(hold);
|
|
37
|
+
agent.stop().finally(() => process.exit(0));
|
|
38
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
39
|
+
};
|
|
40
|
+
process.on('SIGTERM', shutdown);
|
|
41
|
+
process.on('SIGINT', shutdown);
|
|
42
|
+
return agent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Whether a home holds a forum rather than a person or a group. The credential
|
|
46
|
+
// names the root its documents live under, and a forum's is its own.
|
|
47
|
+
export function isForumHome(home) {
|
|
48
|
+
try {
|
|
49
|
+
const cred = JSON.parse(fs.readFileSync(`${home.replace(/\/$/u, '')}/credential.json`, 'utf8'));
|
|
50
|
+
return typeof cred.root === 'string' && cred.root.replace(/\/$/u, '') === 'fedipod-bb';
|
|
51
|
+
} catch { return false; }
|
|
52
|
+
}
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// settings.mjs — changing what the forum IS, asked for the way everything
|
|
2
|
+
// else is asked for: a moderator publishes the request at their own pod and
|
|
3
|
+
// the forum fetches it back from there before acting (FEP-fe34).
|
|
4
|
+
//
|
|
5
|
+
// Every request is ordinary ActivityStreams. A category is created by a
|
|
6
|
+
// Create of a Group naming the forum; a name is changed by an Update; who
|
|
7
|
+
// moderates and who may read are Add and Remove naming the collection they
|
|
8
|
+
// belong to. Nothing here invents a word.
|
|
9
|
+
|
|
10
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
11
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/u;
|
|
12
|
+
|
|
13
|
+
// A page reading the forum through a Gateway names what it asks about by the
|
|
14
|
+
// address it read there; read from the pod it names the pod's own. The queue
|
|
15
|
+
// container has no published address at all, since nothing about it is
|
|
16
|
+
// published. So both sides are put in the same space before they are compared,
|
|
17
|
+
// and an ask means what it says whichever address it arrived under.
|
|
18
|
+
const onPod = (forum, u) => (typeof u === 'string' && forum?.toPod ? forum.toPod(u) : u);
|
|
19
|
+
const same = (forum, a, b) => { if (!a || !b) return false; if (a === b) return true; const pa = onPod(forum, a); return !!pa && pa === onPod(forum, b); };
|
|
20
|
+
|
|
21
|
+
// Which of these a delivered activity is, if any. The forum's own actor or
|
|
22
|
+
// one of its collections has to be named, or it is somebody else's business.
|
|
23
|
+
export function isSettingsAsk(forum, activity) {
|
|
24
|
+
const t = activity?.type;
|
|
25
|
+
const target = idOf(activity?.target);
|
|
26
|
+
const object = activity?.object;
|
|
27
|
+
const site = forum.site;
|
|
28
|
+
if (t === 'Create' && object && typeof object === 'object' && object.type === 'Group') {
|
|
29
|
+
return same(forum, idOf(activity.target), site.actor);
|
|
30
|
+
}
|
|
31
|
+
if (t === 'Update' && object && typeof object === 'object') {
|
|
32
|
+
const id = idOf(object);
|
|
33
|
+
return same(forum, id, site.actor) || forum.categories.some(c => same(forum, c.urls.actor, id));
|
|
34
|
+
}
|
|
35
|
+
if (t === 'Add' || t === 'Remove') {
|
|
36
|
+
if (!target) return false;
|
|
37
|
+
if (same(forum, target, site.administrators) || same(forum, target, site.mod)) return true;
|
|
38
|
+
return forum.categories.some(c => same(forum, target, c.urls.moderators) || same(forum, target, c.urls.members));
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Apply one. Returns what changed, for the record the moderators read.
|
|
44
|
+
export async function applySettings(forum, activity) {
|
|
45
|
+
const t = activity?.type;
|
|
46
|
+
const object = activity?.object;
|
|
47
|
+
const target = idOf(activity?.target);
|
|
48
|
+
const site = forum.site;
|
|
49
|
+
const cfg = () => forum.store.getConfig();
|
|
50
|
+
// The record on the pod IS the forum: everything published is written from
|
|
51
|
+
// it, and a start reads it back. A change that never reached it was still
|
|
52
|
+
// published, and the next start quietly republished the old lists over the
|
|
53
|
+
// top — a moderator added, announced, and gone again with nothing said. So
|
|
54
|
+
// the change is written first, and a write that fails puts memory back and
|
|
55
|
+
// says so instead of going on to publish.
|
|
56
|
+
const save = async (next) => {
|
|
57
|
+
const before = cfg();
|
|
58
|
+
forum.store.setConfig({ ...before, ...next });
|
|
59
|
+
forum.config = forum.store.getConfig();
|
|
60
|
+
try {
|
|
61
|
+
await forum.store.flush();
|
|
62
|
+
} catch (e) {
|
|
63
|
+
forum.store.setConfig(before);
|
|
64
|
+
forum.config = forum.store.getConfig();
|
|
65
|
+
throw new Error(`the forum's own record could not be written (${e.message}) — nothing was changed`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (t === 'Create' && object?.type === 'Group') {
|
|
70
|
+
const slug = String(object.preferredUsername || '').toLowerCase();
|
|
71
|
+
if (!SLUG.test(slug)) throw new Error(`not a category slug: ${slug}`);
|
|
72
|
+
const cats = cfg().categories || [];
|
|
73
|
+
if (cats.some(c => c.slug === slug)) return { unchanged: slug };
|
|
74
|
+
// Private: joining is approved by a moderator (AS2's own
|
|
75
|
+
// manuallyApprovesFollowers) and only those admitted may read it. It
|
|
76
|
+
// starts readable by the moderators, since a category nobody can read is
|
|
77
|
+
// a category nobody can moderate.
|
|
78
|
+
const priv = object.manuallyApprovesFollowers === true;
|
|
79
|
+
const next = {
|
|
80
|
+
categories: [...cats, { slug, name: String(object.name || slug).slice(0, 200), private: priv }],
|
|
81
|
+
republish: true,
|
|
82
|
+
};
|
|
83
|
+
if (priv) {
|
|
84
|
+
next.membersOnly = [...new Set([...(cfg().membersOnly || []), slug])];
|
|
85
|
+
next.memberWebIds = { ...(cfg().memberWebIds || {}), [slug]: [...(cfg().moderatorWebIds || [])] };
|
|
86
|
+
next.reprovision = true;
|
|
87
|
+
}
|
|
88
|
+
await save(next);
|
|
89
|
+
return { category: slug, private: priv };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (t === 'Update' && object && typeof object === 'object') {
|
|
93
|
+
const id = idOf(object);
|
|
94
|
+
const name = typeof object.name === 'string' ? object.name.trim().slice(0, 200) : '';
|
|
95
|
+
if (same(forum, id, site.actor)) {
|
|
96
|
+
if (!name) return {};
|
|
97
|
+
await save({ name, republish: true });
|
|
98
|
+
return { forum: name };
|
|
99
|
+
}
|
|
100
|
+
const cat = forum.categories.find(c => same(forum, c.urls.actor, id));
|
|
101
|
+
if (!cat) return {};
|
|
102
|
+
const out = {};
|
|
103
|
+
if (name) {
|
|
104
|
+
await save({ categories: (cfg().categories || []).map(c => (c.slug === cat.slug ? { ...c, name } : c)), republish: true });
|
|
105
|
+
out.category = cat.slug;
|
|
106
|
+
out.name = name;
|
|
107
|
+
}
|
|
108
|
+
// Open or private is the forum's OWN setting, and this is the only thing
|
|
109
|
+
// that changes it: not who joins, not who is named a member, not how many
|
|
110
|
+
// of either there are. AS2 already says it on a Group — a private
|
|
111
|
+
// category approves each join — so `manuallyApprovesFollowers` is the
|
|
112
|
+
// word for it, and the actor the forum publishes carries the same one.
|
|
113
|
+
if ('manuallyApprovesFollowers' in object) {
|
|
114
|
+
const priv = object.manuallyApprovesFollowers === true;
|
|
115
|
+
const closed = new Set(cfg().membersOnly || []);
|
|
116
|
+
if (priv) closed.add(cat.slug); else closed.delete(cat.slug);
|
|
117
|
+
// Turning a category private with nobody named yet leaves its
|
|
118
|
+
// moderators able to read it: a category nobody can read is a category
|
|
119
|
+
// nobody can moderate.
|
|
120
|
+
const named = { ...(cfg().memberWebIds || {}) };
|
|
121
|
+
if (priv && !(named[cat.slug] || []).length) named[cat.slug] = [...(cfg().moderatorWebIds || [])];
|
|
122
|
+
await save({
|
|
123
|
+
membersOnly: [...closed], memberWebIds: named,
|
|
124
|
+
categories: (cfg().categories || []).map(c => (c.slug === cat.slug ? { ...c, private: priv } : c)),
|
|
125
|
+
republish: true, reprovision: true,
|
|
126
|
+
});
|
|
127
|
+
out.category = cat.slug;
|
|
128
|
+
out.private = priv;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const who = idOf(object);
|
|
134
|
+
if ((t === 'Add' || t === 'Remove') && who) {
|
|
135
|
+
const on = t === 'Add';
|
|
136
|
+
// Who moderates: an actor id, which is what the wire and FEP-1b12 use.
|
|
137
|
+
const cat = forum.categories.find(c => same(forum, target, c.urls.moderators) || same(forum, target, c.urls.members));
|
|
138
|
+
if (same(forum, target, site.administrators) || (cat && same(forum, target, cat.urls.moderators))) {
|
|
139
|
+
const held = new Set(cfg().moderators || []);
|
|
140
|
+
if (on) held.add(who); else held.delete(who);
|
|
141
|
+
await save({ moderators: [...held], republish: true });
|
|
142
|
+
// The forum's own list is what the website reads, so it is written now
|
|
143
|
+
// rather than at the next start.
|
|
144
|
+
await forum.republishAdministrators?.().catch(() => {});
|
|
145
|
+
return { moderators: held.size };
|
|
146
|
+
}
|
|
147
|
+
// Who may read a members-only category, and who may read the queue: a
|
|
148
|
+
// WebID, because only a WebID can be named in a pod's own access rule.
|
|
149
|
+
// A member named by their Fediverse actor rather than their WebID: what
|
|
150
|
+
// a moderator has in front of them when they admit a join request. The
|
|
151
|
+
// WebID is the pod the actor lives on, checked against that pod's own
|
|
152
|
+
// profile before it is granted anything.
|
|
153
|
+
if (cat && same(forum, target, cat.urls.members) && !/#|\/profile\//u.test(who)) {
|
|
154
|
+
const webid = await forum.webIdOf(who).catch(() => null);
|
|
155
|
+
if (!webid) throw new Error(`no WebID could be found for ${who}`);
|
|
156
|
+
return applySettings(forum, { ...activity, object: webid });
|
|
157
|
+
}
|
|
158
|
+
if (cat && same(forum, target, cat.urls.members)) {
|
|
159
|
+
const named = { ...(cfg().memberWebIds || {}) };
|
|
160
|
+
const held = new Set(named[cat.slug] || []);
|
|
161
|
+
if (on) held.add(who); else held.delete(who);
|
|
162
|
+
named[cat.slug] = [...held];
|
|
163
|
+
// Naming a member says NOTHING about whether the category is open or
|
|
164
|
+
// private. That is the forum's own setting and only an Update changes
|
|
165
|
+
// it; in an open category this list simply sits there unused.
|
|
166
|
+
await save({ memberWebIds: named, republish: true, reprovision: true });
|
|
167
|
+
return { category: cat.slug, members: held.size };
|
|
168
|
+
}
|
|
169
|
+
// Who may read the queue, named the way every moderator is named: by
|
|
170
|
+
// their Fediverse actor. The rule that holds the queue is the pod's own
|
|
171
|
+
// and can only name a WebID, so the pod behind the actor is looked up
|
|
172
|
+
// here; an account with no pod cannot be granted it at all.
|
|
173
|
+
if (same(forum, target, site.mod) && !/#|\/profile\//u.test(who)) {
|
|
174
|
+
const webid = await forum.webIdOf(who).catch(() => null);
|
|
175
|
+
if (!webid) throw new Error(`no WebID could be found for ${who}`);
|
|
176
|
+
return applySettings(forum, { ...activity, object: webid });
|
|
177
|
+
}
|
|
178
|
+
if (same(forum, target, site.mod)) {
|
|
179
|
+
const held = new Set(cfg().moderatorWebIds || []);
|
|
180
|
+
if (on) held.add(who); else held.delete(who);
|
|
181
|
+
await save({ moderatorWebIds: [...held], republish: true, reprovision: true });
|
|
182
|
+
return { moderatorWebIds: held.size };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {};
|
|
186
|
+
}
|
package/src/topics.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// topics.mjs — a category's topic record: what the host knows about its
|
|
2
|
+
// topics and their posts, kept in the category's owner-only state as one
|
|
3
|
+
// document per topic and one list of them. The published collections are
|
|
4
|
+
// built from this record; the record is never rebuilt from them.
|
|
5
|
+
//
|
|
6
|
+
// `topics.json` [{ tid, title, op, author, created, last, count, pinned, locked }]
|
|
7
|
+
// `topics/<tid>.json` { tid, title, posts: [{ id, author, published, inReplyTo, cached }],
|
|
8
|
+
// index: [[ids]], pages: { n: digest }, headDigest }
|
|
9
|
+
|
|
10
|
+
import { isTid } from './urls.mjs';
|
|
11
|
+
|
|
12
|
+
export const TOPICS = 'topics.json';
|
|
13
|
+
// Flat, not `topics/<tid>.json`: state is loaded by listing ONE container and
|
|
14
|
+
// reading the .json in it, so a document in a folder below it is written, never
|
|
15
|
+
// read back, and the forum loses every topic when it restarts.
|
|
16
|
+
export const topicDoc = (tid) => `topic-${tid}.json`;
|
|
17
|
+
export const topicDocOld = (tid) => `topics/${tid}.json`;
|
|
18
|
+
|
|
19
|
+
// A title becomes the tail of a topic id: lower case, letters digits and
|
|
20
|
+
// hyphens, at most eighty characters, never empty.
|
|
21
|
+
export function slugOfTitle(title) {
|
|
22
|
+
const s = String(title || '').toLowerCase()
|
|
23
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 79)
|
|
24
|
+
.replace(/-+$/u, '');
|
|
25
|
+
return s || 'topic';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A topic id that is not yet taken in this category.
|
|
29
|
+
export function mintTid(store, title, published = new Date().toISOString()) {
|
|
30
|
+
const month = String(published).slice(0, 7);
|
|
31
|
+
const base = `${month}-${slugOfTitle(title)}`;
|
|
32
|
+
const taken = new Set(list(store).map(t => t.tid));
|
|
33
|
+
let tid = base;
|
|
34
|
+
for (let n = 2; taken.has(tid); n++) tid = `${base.slice(0, 76)}-${n}`;
|
|
35
|
+
return tid;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function list(store) { return store.read(TOPICS, []); }
|
|
39
|
+
|
|
40
|
+
export function get(store, tid) {
|
|
41
|
+
if (!isTid(tid)) return null;
|
|
42
|
+
return store.read(topicDoc(tid), null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Which topic holds a post, by the post's id.
|
|
46
|
+
export function topicOf(store, postId) {
|
|
47
|
+
for (const t of list(store)) {
|
|
48
|
+
const doc = get(store, t.tid);
|
|
49
|
+
if (doc?.posts?.some(p => p.id === postId)) return t.tid;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Open a topic with its first post. Returns the topic id.
|
|
55
|
+
export function open(store, { title, post, tid = null }) {
|
|
56
|
+
const id = tid || mintTid(store, title, post.published);
|
|
57
|
+
if (get(store, id)) throw new Error(`topic already open: ${id}`);
|
|
58
|
+
const entry = { tid: id, title: String(title), op: post.id, author: post.author,
|
|
59
|
+
created: post.published, last: post.published, count: 1, pinned: false, locked: false };
|
|
60
|
+
store.write(TOPICS, [...list(store), entry]);
|
|
61
|
+
store.write(topicDoc(id), { tid: id, title: entry.title, posts: [post], index: [], pages: {}, headDigest: null });
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add a post to a topic. A post already there is not added twice.
|
|
66
|
+
export function append(store, tid, post) {
|
|
67
|
+
const doc = get(store, tid);
|
|
68
|
+
if (!doc) throw new Error(`no such topic: ${tid}`);
|
|
69
|
+
if (doc.posts.some(p => p.id === post.id)) return false;
|
|
70
|
+
doc.posts.push(post);
|
|
71
|
+
store.write(topicDoc(tid), doc);
|
|
72
|
+
const topics = list(store).map(t => (t.tid === tid
|
|
73
|
+
? { ...t, count: doc.posts.length, last: post.published > t.last ? post.published : t.last } : t));
|
|
74
|
+
store.write(TOPICS, topics);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Take a post out of a topic. Its page stays one short (wire.topicPaging).
|
|
79
|
+
export function remove(store, tid, postId) {
|
|
80
|
+
const doc = get(store, tid);
|
|
81
|
+
if (!doc) return false;
|
|
82
|
+
const before = doc.posts.length;
|
|
83
|
+
doc.posts = doc.posts.filter(p => p.id !== postId);
|
|
84
|
+
if (doc.posts.length === before) return false;
|
|
85
|
+
store.write(topicDoc(tid), doc);
|
|
86
|
+
store.write(TOPICS, list(store).map(t => (t.tid === tid ? { ...t, count: doc.posts.length } : t)));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setTitle(store, tid, title) {
|
|
91
|
+
const doc = get(store, tid);
|
|
92
|
+
if (!doc) return null;
|
|
93
|
+
store.write(topicDoc(tid), { ...doc, title });
|
|
94
|
+
store.write(TOPICS, list(store).map(t => (t.tid === tid ? { ...t, title } : t)));
|
|
95
|
+
return title;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function setFlags(store, tid, { pinned, locked } = {}) {
|
|
99
|
+
store.write(TOPICS, list(store).map(t => (t.tid === tid
|
|
100
|
+
? { ...t, ...(pinned !== undefined ? { pinned: !!pinned } : {}), ...(locked !== undefined ? { locked: !!locked } : {}) } : t)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── placing a carried post ─────────────────────────────────────────────
|
|
104
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
105
|
+
const MAX_PARENT_HOPS = 20;
|
|
106
|
+
|
|
107
|
+
// A post's title: what it says it is, else its first words.
|
|
108
|
+
export function titleOf(note) {
|
|
109
|
+
if (typeof note?.name === 'string' && note.name.trim()) return note.name.trim().slice(0, 200);
|
|
110
|
+
const text = String(note?.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
111
|
+
const words = text.split(' ').slice(0, 12).join(' ');
|
|
112
|
+
return (words.length < text.length ? words + '…' : words) || 'Untitled';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Which topic a carried post belongs to, and put it there. In order: the
|
|
116
|
+
// topic its `context` names; the topic its reply chain leads to, walking up
|
|
117
|
+
// through parents fetched at their origins; else a new topic with this post
|
|
118
|
+
// as its opening. Returns the topic id.
|
|
119
|
+
export async function assign({ store, urls, fetchAP }, note, activity = null) {
|
|
120
|
+
const post = {
|
|
121
|
+
id: note.id,
|
|
122
|
+
author: idOf([].concat(note.attributedTo || [])[0]) || null,
|
|
123
|
+
published: note.published || new Date().toISOString(),
|
|
124
|
+
inReplyTo: idOf(note.inReplyTo) || null,
|
|
125
|
+
cached: urls.cached(note.id),
|
|
126
|
+
};
|
|
127
|
+
const named = idOf(note.context);
|
|
128
|
+
if (named && named.startsWith(urls.topicContainer)) {
|
|
129
|
+
const tail = named.slice(urls.topicContainer.length);
|
|
130
|
+
const tid = get(store, tail) ? tail : tail.replace(/-\d+$/u, '');
|
|
131
|
+
if (get(store, tid)) { append(store, tid, post); return tid; }
|
|
132
|
+
}
|
|
133
|
+
let parent = post.inReplyTo;
|
|
134
|
+
for (let hop = 0; parent && hop < MAX_PARENT_HOPS; hop++) {
|
|
135
|
+
const tid = topicOf(store, parent);
|
|
136
|
+
if (tid) { append(store, tid, post); return tid; }
|
|
137
|
+
const doc = await fetchAP(parent).catch(() => null);
|
|
138
|
+
// A post whose own conversation is one of ours: the walk can stop, even
|
|
139
|
+
// though the post in hand said nothing about it. This is what lets a
|
|
140
|
+
// reply written on a server that knows about conversations but not about
|
|
141
|
+
// us — and any post quoting another's `context` — land where it belongs
|
|
142
|
+
// (FEP-7888, and the reading half of FEP-f228).
|
|
143
|
+
const theirs = idOf(doc?.context);
|
|
144
|
+
if (theirs && theirs.startsWith(urls.topicContainer)) {
|
|
145
|
+
const tail = theirs.slice(urls.topicContainer.length);
|
|
146
|
+
const named = get(store, tail) ? tail : tail.replace(/-\d+$/u, '');
|
|
147
|
+
if (get(store, named)) { append(store, named, post); return named; }
|
|
148
|
+
}
|
|
149
|
+
parent = idOf(doc?.inReplyTo) || null;
|
|
150
|
+
}
|
|
151
|
+
// The name of a new topic, in order: what the opening activity called it —
|
|
152
|
+
// a client here asks for a topic BY NAME, which is a different thing from
|
|
153
|
+
// the post's own title — then the post's title, then its first words, for
|
|
154
|
+
// everything arriving from servers that have no notion of a topic.
|
|
155
|
+
const asked = typeof activity?.name === 'string' ? activity.name.trim().slice(0, 200) : '';
|
|
156
|
+
return open(store, { title: asked || titleOf(note), post });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A topic gone from the record: its list entry and its document.
|
|
160
|
+
export function drop(store, tid) {
|
|
161
|
+
if (!get(store, tid)) return false;
|
|
162
|
+
store.write(TOPICS, list(store).filter(t => t.tid !== tid));
|
|
163
|
+
store.remove?.(topicDoc(tid));
|
|
164
|
+
return true;
|
|
165
|
+
}
|