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/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
+ }