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/urls.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// urls.mjs — where a forum's documents live on its pod.
|
|
2
|
+
//
|
|
3
|
+
// A forum is one root holding a site actor and, under `c/`, one FediPod group
|
|
4
|
+
// root per category. The group roots are `apUrls` verbatim, so every module
|
|
5
|
+
// that publishes or drains a group works on a category unchanged; the forum
|
|
6
|
+
// adds the topic documents beside them and one inbox above them.
|
|
7
|
+
//
|
|
8
|
+
// The root is this application's answer, stated here: the pod library refuses
|
|
9
|
+
// to guess one, and a forum is one application.
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { apUrls } from 'fedipod/pod/urls.mjs';
|
|
13
|
+
|
|
14
|
+
export const ROOT = 'fedipod-bb/';
|
|
15
|
+
|
|
16
|
+
// A category's slug is its handle's local part and a container name: lower
|
|
17
|
+
// case letters, digits and hyphens, starting with a letter or digit.
|
|
18
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/u;
|
|
19
|
+
export const isSlug = (s) => typeof s === 'string' && SLUG.test(s);
|
|
20
|
+
|
|
21
|
+
// A topic's id on the pod: the month it opened and a slug of its title, which
|
|
22
|
+
// keeps a category's topic container readable to a person listing it.
|
|
23
|
+
export const TID = /^[0-9]{4}-[0-9]{2}-[a-z0-9][a-z0-9-]{0,78}$/u;
|
|
24
|
+
export const isTid = (s) => typeof s === 'string' && TID.test(s);
|
|
25
|
+
|
|
26
|
+
// The name a cached copy is filed under: a post's id is a URL and a URL is
|
|
27
|
+
// not a file name, so the copy takes a digest of it.
|
|
28
|
+
export const cacheKey = (postId) => crypto.createHash('sha256').update(String(postId)).digest('hex').slice(0, 16);
|
|
29
|
+
|
|
30
|
+
// `front` and `handle`: a forum reachable through a Gateway. Its own actor
|
|
31
|
+
// answers at `<front>/u/<handle>/`, each category at `<front>/u/<slug>/`,
|
|
32
|
+
// and every advertised id is rewritten onto the pod at the transport's one
|
|
33
|
+
// choke point (`toPod`). Without a front the ids are the pod's own.
|
|
34
|
+
export function forumUrls(remotePod, root = ROOT, { publicBase = null, front = null, handle = null } = {}) {
|
|
35
|
+
const origin = front ? String(front).replace(/\/$/u, '') : null;
|
|
36
|
+
if (origin && !handle) throw new Error('forumUrls: a fronted forum needs its handle');
|
|
37
|
+
const site = apUrls(remotePod, root, { publicBase: publicBase || (origin ? `${origin}/u/${handle}/` : null) });
|
|
38
|
+
const face = site.actor.slice(0, -'ap/actor'.length);
|
|
39
|
+
site.front = origin;
|
|
40
|
+
site.categories = face + 'ap/categories';
|
|
41
|
+
site.administrators = face + 'ap/administrators';
|
|
42
|
+
// Everything the forum holds, newest first, across every category: what a
|
|
43
|
+
// reader arriving at the forum sees before they know its categories.
|
|
44
|
+
site.latest = face + 'ap/latest';
|
|
45
|
+
// The moderators' own container: the queue of reports and held posts,
|
|
46
|
+
// readable by the moderators' WebIDs and nobody else. Under the pod's own
|
|
47
|
+
// root, not the advertised face — nothing here is published.
|
|
48
|
+
site.mod = (remotePod.endsWith('/') ? remotePod : remotePod + '/') + (root.endsWith('/') ? root : root + '/') + 'mod/';
|
|
49
|
+
site.siteHtml = face + 'ap/site.html';
|
|
50
|
+
site.root = root.endsWith('/') ? root : root + '/';
|
|
51
|
+
site.category = (slug) => {
|
|
52
|
+
if (!isSlug(slug)) throw new Error(`forumUrls: not a category slug (${slug})`);
|
|
53
|
+
return categoryUrls(remotePod, site.root + 'c/' + slug + '/', {
|
|
54
|
+
publicBase: origin ? `${origin}/u/${slug}/` : (publicBase ? face + 'c/' + slug + '/' : null),
|
|
55
|
+
forumInbox: site.inbox,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
return site;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A category: a group root plus the topic documents. `forumInbox` is what
|
|
62
|
+
// the category advertises as its inbox — deliveries to any category land in
|
|
63
|
+
// the forum's one inbox, and the host routes them.
|
|
64
|
+
export function categoryUrls(remotePod, root, { publicBase = null, forumInbox = null } = {}) {
|
|
65
|
+
const urls = apUrls(remotePod, root, { publicBase });
|
|
66
|
+
const face = urls.actor.slice(0, -'ap/actor'.length);
|
|
67
|
+
urls.forumInbox = forumInbox || urls.inbox;
|
|
68
|
+
// The category's topics, newest first, paged like an outbox: a head under
|
|
69
|
+
// ap/ and page documents beside it.
|
|
70
|
+
urls.topics = face + 'ap/topics';
|
|
71
|
+
urls.topicsPage = (n) => `${urls.topics}-${n}`;
|
|
72
|
+
// One topic: its head and its pages live in a public container, so a new
|
|
73
|
+
// page inherits the container's rule and needs none of its own.
|
|
74
|
+
urls.topicContainer = face + 'ap/topic/';
|
|
75
|
+
urls.topic = (tid) => {
|
|
76
|
+
if (!isTid(tid)) throw new Error(`categoryUrls: not a topic id (${tid})`);
|
|
77
|
+
return urls.topicContainer + tid;
|
|
78
|
+
};
|
|
79
|
+
urls.topicPage = (tid, n) => `${urls.topic(tid)}-${n}`;
|
|
80
|
+
// Readable copies of members' posts, for the website.
|
|
81
|
+
urls.cache = face + 'ap/cache/';
|
|
82
|
+
urls.cached = (postId) => urls.cache + cacheKey(postId);
|
|
83
|
+
// Who may read a members-only category: the collection an Add or a Remove
|
|
84
|
+
// names when someone is let in or out. Nothing is published there; it is a
|
|
85
|
+
// name to address, and the pod's own access rule is what enforces it.
|
|
86
|
+
urls.members = face + 'ap/members';
|
|
87
|
+
urls.categoryHtml = face + 'ap/index.html';
|
|
88
|
+
urls.topicHtml = (tid) => face + 'ap/t/' + tid + '.html';
|
|
89
|
+
return urls;
|
|
90
|
+
}
|
package/src/wire.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// wire.mjs — the forum's documents as other servers read them: a topic as a
|
|
2
|
+
// context collection (FEP-7888), a category's topic list, the forum's
|
|
3
|
+
// category and administrator lists, and the site actor. Every term is
|
|
4
|
+
// ActivityStreams; the group actor a category publishes is FediPod's own.
|
|
5
|
+
|
|
6
|
+
import { AS_CTX, pageItems, orderedCollection, actorDoc } from 'fedipod/core/wire.mjs';
|
|
7
|
+
|
|
8
|
+
export const TOPIC_PAGE_SIZE = 20;
|
|
9
|
+
export const TOPICS_PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
// ── a topic ────────────────────────────────────────────────────────────
|
|
12
|
+
// The head is what a post's `context` names: the collection's owner is the
|
|
13
|
+
// category (`attributedTo`), the audience is the category, and the pages
|
|
14
|
+
// hold the posts in the order they were said. Oldest first: a thread is
|
|
15
|
+
// read forwards, so `first` is page 1.
|
|
16
|
+
export function topicHead({ id, name, category, total, pageCount, published, updated = null, closed = null }) {
|
|
17
|
+
const pages = Math.max(1, pageCount);
|
|
18
|
+
return {
|
|
19
|
+
'@context': AS_CTX,
|
|
20
|
+
id, type: 'OrderedCollection',
|
|
21
|
+
name,
|
|
22
|
+
attributedTo: category,
|
|
23
|
+
audience: category,
|
|
24
|
+
totalItems: total,
|
|
25
|
+
first: `${id}-1`,
|
|
26
|
+
last: `${id}-${pages}`,
|
|
27
|
+
published,
|
|
28
|
+
...(updated ? { updated } : {}),
|
|
29
|
+
// Closed: this topic takes no more replies (AS2 `closed`).
|
|
30
|
+
...(closed ? { closed } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A page carries `prev` and `next` both ways. Writing page n+1 rewrites page
|
|
35
|
+
// n once, to give it a `next`; nothing else on a sealed page ever changes.
|
|
36
|
+
export function topicPage({ id, n, items, pageCount }) {
|
|
37
|
+
return {
|
|
38
|
+
'@context': AS_CTX,
|
|
39
|
+
id: `${id}-${n}`,
|
|
40
|
+
type: 'OrderedCollectionPage',
|
|
41
|
+
partOf: id,
|
|
42
|
+
...(n > 1 ? { prev: `${id}-${n - 1}` } : {}),
|
|
43
|
+
...(n < pageCount ? { next: `${id}-${n + 1}` } : {}),
|
|
44
|
+
orderedItems: items,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Posts in the order they were said; the assignment is kept so a removed
|
|
49
|
+
// post leaves its page one short rather than re-slicing every page after it.
|
|
50
|
+
export function topicPaging(postIds, index = []) {
|
|
51
|
+
const byId = new Map();
|
|
52
|
+
for (const id of postIds) if (typeof id === 'string' && !byId.has(id)) byId.set(id, id);
|
|
53
|
+
return pageItems({ order: postIds, byId, index, pageSize: TOPIC_PAGE_SIZE });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── a category's topics ────────────────────────────────────────────────
|
|
57
|
+
// Newest first, the way an outbox is read: `first` is the newest page and
|
|
58
|
+
// `next` walks back in time. Pages are numbered from the oldest end so
|
|
59
|
+
// opening a topic moves only the newest page.
|
|
60
|
+
export function topicsHead({ id, category, total, pageCount }) {
|
|
61
|
+
const pages = Math.max(1, pageCount);
|
|
62
|
+
return {
|
|
63
|
+
'@context': AS_CTX,
|
|
64
|
+
id, type: 'OrderedCollection',
|
|
65
|
+
attributedTo: category,
|
|
66
|
+
totalItems: total,
|
|
67
|
+
first: `${id}-${pages}`,
|
|
68
|
+
last: `${id}-1`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function topicsPage({ id, n, items }) {
|
|
73
|
+
return {
|
|
74
|
+
'@context': AS_CTX,
|
|
75
|
+
id: `${id}-${n}`,
|
|
76
|
+
type: 'OrderedCollectionPage',
|
|
77
|
+
partOf: id,
|
|
78
|
+
orderedItems: [...items].reverse(), // newest first within the page
|
|
79
|
+
...(n > 1 ? { next: `${id}-${n - 1}` } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `topicIds` oldest first, as the record keeps them.
|
|
84
|
+
export function topicsPaging(topicIds, index = []) {
|
|
85
|
+
const byId = new Map();
|
|
86
|
+
for (const id of topicIds) if (typeof id === 'string' && !byId.has(id)) byId.set(id, id);
|
|
87
|
+
return pageItems({ order: topicIds, byId, index, pageSize: TOPICS_PAGE_SIZE });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── the forum ──────────────────────────────────────────────────────────
|
|
91
|
+
export const categoriesCollection = (id, actorIds) => orderedCollection(id, actorIds);
|
|
92
|
+
// Who may read a private category: the WebIDs its posts are written for. Its
|
|
93
|
+
// readers are its items — nobody outside the list can read the list — and the
|
|
94
|
+
// forum's own WebID is one of them, because the forum fetches every post back
|
|
95
|
+
// from its author's pod before it carries it.
|
|
96
|
+
export const membersCollection = (id, webIds) => orderedCollection(id, webIds);
|
|
97
|
+
export const administratorsCollection = (id, actorIds) => orderedCollection(id, actorIds);
|
|
98
|
+
// The latest posts: the forum's own copies of them, newest first. Its items
|
|
99
|
+
// are the copies rather than the authors' ids, because one fetch of a copy
|
|
100
|
+
// tells a reader everything — who wrote it, when, and which topic it is in.
|
|
101
|
+
export const latestCollection = (id, copyUrls) => orderedCollection(id, copyUrls);
|
|
102
|
+
|
|
103
|
+
// The forum's own actor: an Application, the service that speaks for the
|
|
104
|
+
// site. Everything else is what any FediPod actor carries.
|
|
105
|
+
export function siteActorDoc(opts) {
|
|
106
|
+
return actorDoc({ ...opts, kind: 'application' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── a cached copy ──────────────────────────────────────────────────────
|
|
110
|
+
// What a removed copy becomes: a Tombstone that says what it was, so a
|
|
111
|
+
// reader landing on it knows a post stood here (FEP-4f05).
|
|
112
|
+
export function cachedTombstone({ id, formerType = 'Note', deleted = new Date().toISOString() }) {
|
|
113
|
+
return { '@context': AS_CTX, id, type: 'Tombstone', formerType, deleted };
|
|
114
|
+
}
|