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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// forum-intake.mjs — the forum's inbox, drained by one loop and handed out by
|
|
2
|
+
// address. Every mechanism of a drain — listing, reading, receipts, dead
|
|
3
|
+
// letters, the commit-before-delete rule — is the Intake's; only handle()
|
|
4
|
+
// differs.
|
|
5
|
+
|
|
6
|
+
import { Intake } from 'fedipod/core/intake/index.mjs';
|
|
7
|
+
import * as podInbox from 'fedipod/pod/inbox.mjs';
|
|
8
|
+
import * as settings from './settings.mjs';
|
|
9
|
+
|
|
10
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
11
|
+
|
|
12
|
+
// A vote, as Lemmy federates one: a Like for up, a Dislike for down, and the
|
|
13
|
+
// Undo of either to take it back. Both are ordinary ActivityStreams. Anything
|
|
14
|
+
// else is not a vote.
|
|
15
|
+
export function voteIn(activity) {
|
|
16
|
+
const t = activity?.type;
|
|
17
|
+
const inner = activity?.object && typeof activity.object === 'object' ? activity.object : null;
|
|
18
|
+
if (t === 'Like') return { post: idOf(activity.object), actor: idOf(activity.actor), way: 'up' };
|
|
19
|
+
if (t === 'Dislike') return { post: idOf(activity.object), actor: idOf(activity.actor), way: 'down' };
|
|
20
|
+
if (t === 'Undo' && (inner?.type === 'Like' || inner?.type === 'Dislike')) {
|
|
21
|
+
return { post: idOf(inner.object), actor: idOf(activity.actor), way: 'none' };
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ForumIntake extends Intake {
|
|
27
|
+
constructor(opts, forum) {
|
|
28
|
+
super(opts);
|
|
29
|
+
this.forum = forum;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(activity, receipt = null) {
|
|
33
|
+
const vote = voteIn(activity);
|
|
34
|
+
if (vote) {
|
|
35
|
+
const done = await this.forum.countVote(vote);
|
|
36
|
+
if (done) return undefined;
|
|
37
|
+
}
|
|
38
|
+
// A moderator ask is the forum's whichever list it names: moderators are
|
|
39
|
+
// forum-wide. Asked before the categories, or a category took an ask
|
|
40
|
+
// naming its own list as a roster change and the forum never heard.
|
|
41
|
+
if ((activity?.type === 'Add' || activity?.type === 'Remove') && settings.isSettingsAsk(this.forum, activity)) {
|
|
42
|
+
const who = idOf(activity?.actor);
|
|
43
|
+
if (!(this.forum.config.moderators || []).includes(who)) return 'only a moderator may change this forum';
|
|
44
|
+
this.queueModeration(activity, who, { trusted: false });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const cats = this.forum.route(activity);
|
|
48
|
+
if (cats.length) {
|
|
49
|
+
const results = [];
|
|
50
|
+
for (const cat of cats) results.push(await cat.intake.handle(activity, receipt));
|
|
51
|
+
// Accepted by any category is accepted; refused by all is the first reason.
|
|
52
|
+
return results.every(r => r) ? results[0] : undefined;
|
|
53
|
+
}
|
|
54
|
+
if (settings.isSettingsAsk(this.forum, activity)) {
|
|
55
|
+
const who = idOf(activity?.actor);
|
|
56
|
+
if (!(this.forum.config.moderators || []).includes(who)) return 'only a moderator may change this forum';
|
|
57
|
+
this.queueModeration(activity, who, { trusted: false });
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (this.forum.namesSite(activity)) return super.handle(activity, receipt);
|
|
61
|
+
return 'names no category of this forum';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One commit boundary for every category too. The base drain holds only
|
|
65
|
+
// the forum's own store, so a category's topic, index and people documents
|
|
66
|
+
// went to the pod once per item instead of once per batch.
|
|
67
|
+
async drain() {
|
|
68
|
+
for (const cat of this.forum.categories) cat.store.hold?.();
|
|
69
|
+
try { return await super.drain(); }
|
|
70
|
+
finally { for (const cat of this.forum.categories) cat.store.release?.(); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every category's state has to be on the pod before an item leaves the
|
|
74
|
+
// inbox, not only the forum's own.
|
|
75
|
+
async _persisted() {
|
|
76
|
+
let ok = await this.store.commit();
|
|
77
|
+
for (const cat of this.forum.categories) ok = (await cat.store.commit()) && ok;
|
|
78
|
+
return ok;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Categories carry (FEP-1b12); the forum's own inbox forwards nothing.
|
|
82
|
+
async _maybeForward() {}
|
|
83
|
+
|
|
84
|
+
// A receipt beside an item was signed by the door it came through, and each
|
|
85
|
+
// category's door has its own secret: the receipt is ours if any of them
|
|
86
|
+
// verifies it.
|
|
87
|
+
async _readReceipt(itemUrl) {
|
|
88
|
+
const secrets = [this.config.gateway?.hmacSecret, ...this.forum.categories.map(c => c.config.gateway?.hmacSecret)]
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
if (!secrets.length) return null;
|
|
91
|
+
try {
|
|
92
|
+
const { readCapped } = await import('fedipod/shared/safefetch.mjs');
|
|
93
|
+
const { verifyReceipt } = await import('fedipod/gateway/httpsig.mjs');
|
|
94
|
+
const receipt = await podInbox.readDeliveryReceipt(this.remote, itemUrl, { maxBytes: 64 * 1024, readCapped });
|
|
95
|
+
if (!receipt) return null;
|
|
96
|
+
return secrets.some(s => verifyReceipt(receipt, s)) ? receipt : null;
|
|
97
|
+
} catch { return null; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
gatewaySecret() {
|
|
101
|
+
return this.config.gateway?.hmacSecret || this.forum.categories.find(c => c.config.gateway?.hmacSecret)?.config.gateway.hmacSecret || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The forum owner's own post, taken at an outbox door: written by the
|
|
105
|
+
// category it addresses.
|
|
106
|
+
async ownerPostFrom(activity, raw, receipt) {
|
|
107
|
+
const [cat] = this.forum.route(activity);
|
|
108
|
+
if (!cat) return 'an owner post names no category';
|
|
109
|
+
const r = await cat.c2s.dispatch(activity, { raw, slug: receipt?.slug || null });
|
|
110
|
+
return r.status < 300 ? undefined : `owner post refused (${r.status}: ${r.body?.error || ''})`;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// index.mjs — what FediPod-BB exports.
|
|
2
|
+
|
|
3
|
+
export { ROOT, forumUrls, categoryUrls, cacheKey, isSlug, isTid } from './urls.mjs';
|
|
4
|
+
export * as wire from './wire.mjs';
|
|
5
|
+
export * as topics from './topics.mjs';
|
|
6
|
+
export * as publish from './publish.mjs';
|
|
7
|
+
export * as moderation from './moderation.mjs';
|
|
8
|
+
export { provisionForum, provisionCategory } from './provision.mjs';
|
|
9
|
+
export { ForumAgent, ForumIntake } from './forum-agent.mjs';
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// moderation.mjs — what a moderator does to a topic, and what it says on the
|
|
2
|
+
// wire: a post removed (Delete with the topic as its origin), a topic deleted
|
|
3
|
+
// (Remove from the category, FEP-f15d), a topic moved (Move between
|
|
4
|
+
// categories), a topic pinned (the category's featured collection) or locked
|
|
5
|
+
// (local). Every activity is announced to the category's members the way a
|
|
6
|
+
// group's bans are (FEP-1b12). And the other direction: which arriving
|
|
7
|
+
// activities are a moderator's ask, held for the operator, and how one is
|
|
8
|
+
// applied.
|
|
9
|
+
//
|
|
10
|
+
// A category here is the object the host builds: { urls, store, remote,
|
|
11
|
+
// publisher, deliverer, intake, log }.
|
|
12
|
+
|
|
13
|
+
import { AS_CTX, LEMMY_CTX, PUBLIC, orderedCollection, updateActorActivity } from 'fedipod/core/wire.mjs';
|
|
14
|
+
import { announceModeration, applyModeration } from 'fedipod/core/social.mjs';
|
|
15
|
+
import * as collection from 'fedipod/pod/collection.mjs';
|
|
16
|
+
import * as podNotes from 'fedipod/pod/notes.mjs';
|
|
17
|
+
import * as topics from './topics.mjs';
|
|
18
|
+
import * as publish from './publish.mjs';
|
|
19
|
+
import { applySettings } from './settings.mjs';
|
|
20
|
+
|
|
21
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
22
|
+
|
|
23
|
+
// ── on the wire ────────────────────────────────────────────────────────
|
|
24
|
+
export function removeTopicActivity({ urls, topic, serial }) {
|
|
25
|
+
return { '@context': AS_CTX, id: `${urls.actor}#remove-${serial}`, type: 'Remove', actor: urls.actor,
|
|
26
|
+
object: topic, target: urls.actor, to: [PUBLIC], cc: [urls.followers] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function moveTopicActivity({ urls, topic, origin, target, serial }) {
|
|
30
|
+
return { '@context': AS_CTX, id: `${urls.actor}#move-${serial}`, type: 'Move', actor: urls.actor,
|
|
31
|
+
object: topic, origin, target, to: [PUBLIC], cc: [urls.followers] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The category is not the post's author; what it deletes is the post's place
|
|
35
|
+
// in the topic, which `origin` names.
|
|
36
|
+
export function deletePostActivity({ urls, post, origin, serial }) {
|
|
37
|
+
return { '@context': AS_CTX, id: `${urls.actor}#delete-${serial}`, type: 'Delete', actor: urls.actor,
|
|
38
|
+
object: post, origin, to: [PUBLIC], cc: [urls.followers] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function flagActivity({ urls, objects, content, serial }) {
|
|
42
|
+
return { '@context': AS_CTX, id: `${urls.actor}#flag-${serial}`, type: 'Flag', actor: urls.actor,
|
|
43
|
+
object: [].concat(objects), ...(content ? { content: String(content).slice(0, 2000) } : {}) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tidOf = (cat, id) => {
|
|
47
|
+
if (typeof id !== 'string' || !id.startsWith(cat.urls.topicContainer)) return null;
|
|
48
|
+
const tail = id.slice(cat.urls.topicContainer.length);
|
|
49
|
+
return topics.get(cat.store, tail) ? tail : (topics.get(cat.store, tail.replace(/-\d+$/u, '')) ? tail.replace(/-\d+$/u, '') : null);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── the moderator's acts ───────────────────────────────────────────────
|
|
53
|
+
// A post out of its topic: the copy becomes a tombstone, the page it was on
|
|
54
|
+
// goes one short, the carry is unsaid, and members are told.
|
|
55
|
+
export async function removePost(cat, postId, { announce = true } = {}) {
|
|
56
|
+
const tid = topics.topicOf(cat.store, postId);
|
|
57
|
+
if (!tid) throw new Error(`no topic holds ${postId}`);
|
|
58
|
+
const copy = await cat.remote.getJson(cat.urls.cached(postId)).catch(() => null);
|
|
59
|
+
topics.remove(cat.store, tid, postId);
|
|
60
|
+
await publish.tombstoneCached(cat, postId, { formerType: copy?.type && copy.type !== 'Tombstone' ? copy.type : 'Note' });
|
|
61
|
+
await publish.publishTopic(cat, tid);
|
|
62
|
+
await publish.publishTopicIndex(cat);
|
|
63
|
+
const s = cat.store.getStatuses().find(x => x.noteId === postId);
|
|
64
|
+
if (s?.announceActivity) await cat.intake.retract(postId).catch(e => cat.log?.(`retract ${postId}: ${e.message}`));
|
|
65
|
+
if (announce) {
|
|
66
|
+
await announceModeration(cat, deletePostActivity({ urls: cat.urls, post: postId, origin: cat.urls.topic(tid), serial: Date.now() }));
|
|
67
|
+
}
|
|
68
|
+
return { tid };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A whole topic: announced first as a Remove of the topic from the category,
|
|
72
|
+
// then each post as above, then the topic's documents become a tombstone.
|
|
73
|
+
export async function deleteTopic(cat, tid) {
|
|
74
|
+
const doc = topics.get(cat.store, tid);
|
|
75
|
+
if (!doc) throw new Error(`no such topic: ${tid}`);
|
|
76
|
+
await announceModeration(cat, removeTopicActivity({ urls: cat.urls, topic: cat.urls.topic(tid), serial: Date.now() }));
|
|
77
|
+
for (const p of [...doc.posts]) {
|
|
78
|
+
await publish.tombstoneCached(cat, p.id);
|
|
79
|
+
const s = cat.store.getStatuses().find(x => x.noteId === p.id);
|
|
80
|
+
if (s?.announceActivity) await cat.intake.retract(p.id).catch(e => cat.log?.(`retract ${p.id}: ${e.message}`));
|
|
81
|
+
}
|
|
82
|
+
await dropTopicDocuments(cat, tid, doc);
|
|
83
|
+
topics.drop(cat.store, tid);
|
|
84
|
+
await publish.publishTopicIndex(cat);
|
|
85
|
+
return { removed: doc.posts.length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function dropTopicDocuments(cat, tid, doc) {
|
|
89
|
+
for (const n of Object.keys(doc.pages || {})) await collection.dropPage(cat.remote, cat.urls.topicPage(tid, Number(n)));
|
|
90
|
+
await podNotes.writeTombstone(cat.remote, cat.urls.topic(tid),
|
|
91
|
+
{ '@context': AS_CTX, id: cat.urls.topic(tid), type: 'Tombstone', formerType: 'OrderedCollection', deleted: new Date().toISOString() });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A topic from one category to another: opened afresh there with the same
|
|
95
|
+
// posts and copies, announced as a Move by both, and left as a tombstone here.
|
|
96
|
+
export async function moveTopic(from, to, tid) {
|
|
97
|
+
const doc = topics.get(from.store, tid);
|
|
98
|
+
const entry = topics.list(from.store).find(t => t.tid === tid);
|
|
99
|
+
if (!doc || !entry) throw new Error(`no such topic: ${tid}`);
|
|
100
|
+
const [first, ...rest] = doc.posts;
|
|
101
|
+
const newTid = topics.open(to.store, { title: doc.title, post: first, tid: topics.get(to.store, tid) ? null : tid });
|
|
102
|
+
for (const p of rest) topics.append(to.store, newTid, { ...p, cached: to.urls.cached(p.id) });
|
|
103
|
+
topics.setFlags(to.store, newTid, { pinned: entry.pinned, locked: entry.locked });
|
|
104
|
+
const authors = new Set();
|
|
105
|
+
for (const p of doc.posts) {
|
|
106
|
+
const copy = await from.remote.getJson(from.urls.cached(p.id)).catch(() => null);
|
|
107
|
+
if (copy) await podNotes.write(to.remote, to.urls.cached(p.id), copy);
|
|
108
|
+
if (p.author) authors.add(p.author);
|
|
109
|
+
}
|
|
110
|
+
for (const a of authors) {
|
|
111
|
+
const card = await from.remote.getJson(from.urls.cached(a)).catch(() => null);
|
|
112
|
+
if (card) await podNotes.write(to.remote, to.urls.cached(a), card);
|
|
113
|
+
}
|
|
114
|
+
await publish.publishTopic(to, newTid);
|
|
115
|
+
await publish.publishTopicIndex(to);
|
|
116
|
+
const move = { topic: from.urls.topic(tid), origin: from.urls.actor, target: to.urls.actor, serial: Date.now() };
|
|
117
|
+
await announceModeration(from, moveTopicActivity({ urls: from.urls, ...move }));
|
|
118
|
+
await announceModeration(to, moveTopicActivity({ urls: to.urls, ...move }));
|
|
119
|
+
await dropTopicDocuments(from, tid, doc);
|
|
120
|
+
topics.drop(from.store, tid);
|
|
121
|
+
await publish.publishTopicIndex(from);
|
|
122
|
+
return { tid: newTid, topic: to.urls.topic(newTid) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pinned topics are the category's featured collection; members' servers
|
|
126
|
+
// learn of the change through an Update of the category, and Lemmy through
|
|
127
|
+
// an Update of the opening post (below).
|
|
128
|
+
export async function pinTopic(cat, tid, pinned) {
|
|
129
|
+
if (!topics.get(cat.store, tid)) throw new Error(`no such topic: ${tid}`);
|
|
130
|
+
topics.setFlags(cat.store, tid, { pinned });
|
|
131
|
+
const ids = await publish.publishPinnedTopics(cat);
|
|
132
|
+
const actor = await cat.remote.getJson(cat.urls.actor).catch(() => null);
|
|
133
|
+
const inboxes = memberInboxes(cat);
|
|
134
|
+
if (actor && inboxes.length) {
|
|
135
|
+
await cat.deliverer.deliverToAll(inboxes, updateActorActivity({ urls: cat.urls, actor, serial: Date.now() }));
|
|
136
|
+
}
|
|
137
|
+
await announcePageFlags(cat, tid);
|
|
138
|
+
return { pinned: ids };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const memberInboxes = (cat) => [...new Set(cat.store.getContacts().followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
142
|
+
|
|
143
|
+
// What Lemmy reads a pin and a lock from: the opening post itself, carrying
|
|
144
|
+
// `stickied` and `commentsEnabled` in Lemmy's terms, sent as an Update from
|
|
145
|
+
// the category — the moderator's edit of a post in its community, which is
|
|
146
|
+
// how a Lemmy community tells its subscribers a post was featured or locked.
|
|
147
|
+
// The copy sent is the forum's cached copy of the post, so the object is the
|
|
148
|
+
// whole post and not a fragment. Mastodon drops an Update from anyone but
|
|
149
|
+
// the author, which is the right answer for it.
|
|
150
|
+
export async function announcePageFlags(cat, tid) {
|
|
151
|
+
const entry = topics.list(cat.store).find(t => t.tid === tid);
|
|
152
|
+
const opening = topics.get(cat.store, tid)?.posts?.[0];
|
|
153
|
+
if (!entry || !opening?.id) return null;
|
|
154
|
+
const copy = await cat.remote.getJson(cat.urls.cached(opening.id)).catch(() => null);
|
|
155
|
+
if (!copy?.id) return null;
|
|
156
|
+
const inboxes = memberInboxes(cat);
|
|
157
|
+
if (!inboxes.length) return null;
|
|
158
|
+
const page = { ...copy, '@context': [AS_CTX, LEMMY_CTX], stickied: !!entry.pinned, commentsEnabled: !entry.locked };
|
|
159
|
+
const activity = {
|
|
160
|
+
'@context': [AS_CTX, LEMMY_CTX],
|
|
161
|
+
id: `${cat.urls.actor}#page-${Date.now()}`,
|
|
162
|
+
type: 'Update', actor: cat.urls.actor, to: [PUBLIC], cc: [cat.urls.followers],
|
|
163
|
+
object: page,
|
|
164
|
+
};
|
|
165
|
+
await cat.deliverer.deliverToAll(inboxes, activity);
|
|
166
|
+
return activity;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A topic's name, changed: the record and the published head follow, and
|
|
170
|
+
// nothing about the posts inside it moves.
|
|
171
|
+
export async function renameTopic(cat, tid, name) {
|
|
172
|
+
if (!topics.get(cat.store, tid)) throw new Error(`no such topic: ${tid}`);
|
|
173
|
+
const title = String(name).trim().slice(0, 200);
|
|
174
|
+
if (!title) throw new Error('a topic needs a name');
|
|
175
|
+
topics.setTitle(cat.store, tid, title);
|
|
176
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
177
|
+
await publish.publishTopicIndex(cat, { force: true });
|
|
178
|
+
return { tid, name: title };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Locked: the record says so, and the host places nothing more in it.
|
|
182
|
+
export async function lockTopic(cat, tid, locked) {
|
|
183
|
+
if (!topics.get(cat.store, tid)) throw new Error(`no such topic: ${tid}`);
|
|
184
|
+
topics.setFlags(cat.store, tid, { locked });
|
|
185
|
+
// The topic says so itself, so a reader and any other server can see it:
|
|
186
|
+
// `closed` is what AS2 gives for a collection that takes no more. Lemmy
|
|
187
|
+
// reads it off the opening post instead (announcePageFlags).
|
|
188
|
+
await publish.publishTopic(cat, tid, { force: true });
|
|
189
|
+
await announcePageFlags(cat, tid);
|
|
190
|
+
return { locked: !!locked };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const isLocked = (cat, tid) => !!topics.list(cat.store).find(t => t.tid === tid)?.locked;
|
|
194
|
+
|
|
195
|
+
// ── the other direction: a moderator's ask, arriving ──────────────────
|
|
196
|
+
// Beyond what every group already holds (Block, Undo, Delete of a carried
|
|
197
|
+
// post, roster changes): a Remove or Move of one of our topics, a Delete
|
|
198
|
+
// naming one of our topics as its origin, and a Flag.
|
|
199
|
+
export function isForumAsk(cat, activity) {
|
|
200
|
+
const t = activity?.type;
|
|
201
|
+
if (t === 'Flag') return true;
|
|
202
|
+
// A held post let through, or turned away: both name a post the forum is
|
|
203
|
+
// holding rather than one it has carried.
|
|
204
|
+
if (t === 'Accept' || t === 'Reject') return !!idOf(activity.object);
|
|
205
|
+
// Somebody let into the category: named by their actor, which is what a
|
|
206
|
+
// moderator has in front of them.
|
|
207
|
+
if (t === 'Join') return !!idOf(activity.object);
|
|
208
|
+
if (t === 'Add' || t === 'Remove' || t === 'Move') return !!tidOf(cat, idOf(activity.object));
|
|
209
|
+
// A topic renamed, or closed to further replies: the name and the closing
|
|
210
|
+
// are the topic's own, and only the forum can write them, so a moderator
|
|
211
|
+
// asks for both the way they ask for anything else.
|
|
212
|
+
if (t === 'Update') return !!tidOf(cat, idOf(activity.object));
|
|
213
|
+
if (t === 'Delete') return !!tidOf(cat, idOf(activity.origin));
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Apply one queued ask. `forum` resolves a Move's target to a category.
|
|
218
|
+
export async function applyForumModeration(forum, cat, entry) {
|
|
219
|
+
const object = idOf(entry.activity?.object);
|
|
220
|
+
switch (entry.type) {
|
|
221
|
+
case 'Update': {
|
|
222
|
+
const tid = tidOf(cat, object);
|
|
223
|
+
const asked = typeof entry.activity?.object === 'object' ? entry.activity.object : null;
|
|
224
|
+
if (!tid || !asked) break;
|
|
225
|
+
// `closed` present and truthy: no more replies are placed in it. The
|
|
226
|
+
// STRING "false" is false — a boolean that has been through a form, a
|
|
227
|
+
// template or an RDF round trip arrives as text, and `!!"false"` is true,
|
|
228
|
+
// which turns every reopen into another close.
|
|
229
|
+
if ('closed' in asked) {
|
|
230
|
+
const shut = asked.closed !== false && asked.closed !== 'false' && !!asked.closed;
|
|
231
|
+
cat.log?.(`topic ${tid}: ${shut ? 'closing' : 'reopening'} (asked closed=${JSON.stringify(asked.closed)})`);
|
|
232
|
+
return lockTopic(cat, tid, shut);
|
|
233
|
+
}
|
|
234
|
+
if (asked.name) return renameTopic(cat, tid, asked.name);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
// Somebody admitted to a category: their follow is accepted, and for a
|
|
238
|
+
// private one their pod is granted the reading their membership means.
|
|
239
|
+
case 'Join': {
|
|
240
|
+
if (!object) break;
|
|
241
|
+
// In a private category, membership means reading, and reading is
|
|
242
|
+
// granted to a WebID. Somebody whose server has none cannot be admitted
|
|
243
|
+
// at all — admitting them would carry them posts they may not read.
|
|
244
|
+
const closed = (forum.config?.membersOnly || []).includes(cat.slug);
|
|
245
|
+
if (closed) {
|
|
246
|
+
const webid = await forum.webIdOf(object).catch(() => null);
|
|
247
|
+
if (!webid) throw new Error(`${object} has no WebID this forum can grant reading to`);
|
|
248
|
+
await applySettings(forum, { type: 'Add', object: webid, target: cat.urls.members });
|
|
249
|
+
}
|
|
250
|
+
const { admitRequest } = await import('fedipod/core/social.mjs');
|
|
251
|
+
await admitRequest(cat, object);
|
|
252
|
+
return { admitted: object };
|
|
253
|
+
}
|
|
254
|
+
// A held post let through: the category carries it after all.
|
|
255
|
+
case 'Accept': {
|
|
256
|
+
if (!object) break;
|
|
257
|
+
await cat.intake.amplify(object, { approved: true });
|
|
258
|
+
return { approved: object };
|
|
259
|
+
}
|
|
260
|
+
// Turned away: nothing is carried and the holding ends.
|
|
261
|
+
case 'Reject': {
|
|
262
|
+
if (!object) break;
|
|
263
|
+
cat.store.write('modqueue.json', cat.store.read('modqueue.json', [])
|
|
264
|
+
.filter(e => (typeof e.activity?.object === 'string' ? e.activity.object : e.activity?.object?.id) !== object));
|
|
265
|
+
return { refused: object };
|
|
266
|
+
}
|
|
267
|
+
case 'Add': {
|
|
268
|
+
const tid = tidOf(cat, object);
|
|
269
|
+
if (!tid) break;
|
|
270
|
+
const target = idOf(entry.activity?.target);
|
|
271
|
+
// The forum's own featured collection: pinned for the whole site.
|
|
272
|
+
if (forum.site?.featured && target === forum.site.featured) return forum.sitePin(object, true);
|
|
273
|
+
if (target !== cat.urls.featured) break;
|
|
274
|
+
return pinTopic(cat, tid, true);
|
|
275
|
+
}
|
|
276
|
+
case 'Remove': {
|
|
277
|
+
const tid = tidOf(cat, object);
|
|
278
|
+
if (!tid) break;
|
|
279
|
+
// From the featured collection: unpinned. From the category itself:
|
|
280
|
+
// gone (FEP-f15d).
|
|
281
|
+
const target = idOf(entry.activity?.target);
|
|
282
|
+
if (forum.site?.featured && target === forum.site.featured) return forum.sitePin(object, false);
|
|
283
|
+
if (target === cat.urls.featured) return pinTopic(cat, tid, false);
|
|
284
|
+
return deleteTopic(cat, tid);
|
|
285
|
+
}
|
|
286
|
+
case 'Move': {
|
|
287
|
+
const tid = tidOf(cat, object);
|
|
288
|
+
const to = forum.categories.find(c => c.urls.actor === idOf(entry.activity?.target));
|
|
289
|
+
if (!tid || !to) break;
|
|
290
|
+
return moveTopic(cat, to, tid);
|
|
291
|
+
}
|
|
292
|
+
case 'Delete': {
|
|
293
|
+
if (object && topics.topicOf(cat.store, object)) return removePost(cat, object);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'Flag': {
|
|
297
|
+
cat.store.addNotification({ type: 'flag', actor: entry.moderator, noteId: object || null });
|
|
298
|
+
return { flagged: object || null };
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
}
|
|
302
|
+
return applyModeration(cat, entry);
|
|
303
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// provision.mjs — bringing a forum's containers into being with their access
|
|
2
|
+
// rules, the way FediPod provisions an identity: a canary document and the
|
|
3
|
+
// rule as one operation, idempotent, so running it on every start is safe.
|
|
4
|
+
|
|
5
|
+
import * as containers from 'fedipod/pod/containers.mjs';
|
|
6
|
+
|
|
7
|
+
// The forum level: the site actor's home and state, owner-only; its notes
|
|
8
|
+
// and media, public.
|
|
9
|
+
export async function provisionForum(remote, site, { moderatorWebIds = [] } = {}) {
|
|
10
|
+
await containers.provisionPrivate(remote, site);
|
|
11
|
+
await containers.provisionPublic(remote, site.notes);
|
|
12
|
+
await containers.provisionPublic(remote, site.media);
|
|
13
|
+
// What only the moderators may see: reports name people, and held posts
|
|
14
|
+
// are somebody's words that the forum has not carried. Not public, not
|
|
15
|
+
// owner-only either — each moderator's WebID may read it.
|
|
16
|
+
await remote.putJson(site.mod + '.keep', { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Object' }, 'application/activity+json');
|
|
17
|
+
await remote.setAcl(site.mod, [], { readAgents: moderatorWebIds });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A category: a group's home and state, owner-only; its public trees, plus
|
|
21
|
+
// the topic container and the cache, public. The topic container's rule is
|
|
22
|
+
// what every topic head and page inherits.
|
|
23
|
+
export async function provisionCategory(remote, cat, { memberWebIds = null } = {}) {
|
|
24
|
+
await containers.provisionPrivate(remote, cat);
|
|
25
|
+
for (const base of [cat.notes, cat.media, cat.topicContainer, cat.cache]) {
|
|
26
|
+
// A members-only category: the same containers, readable by the people
|
|
27
|
+
// named rather than by the world. The actor itself stays public — a
|
|
28
|
+
// server that cannot read the actor cannot deliver to the category at
|
|
29
|
+
// all, and members join from outside.
|
|
30
|
+
if (memberWebIds) {
|
|
31
|
+
await remote.putJson(base + '.keep', { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Object' }, 'application/activity+json');
|
|
32
|
+
await remote.setAcl(base, [], { readAgents: memberWebIds });
|
|
33
|
+
} else {
|
|
34
|
+
await containers.provisionPublic(remote, base);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|