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/site/read.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// read.mjs — reading a forum from its pod, as a browser does: the forum's
|
|
2
|
+
// actor, its categories, a category's topics, a topic's posts, and the
|
|
3
|
+
// readable copy of each post. Plain fetches of public documents; nothing here
|
|
4
|
+
// needs an account. Runs in a browser and in Node alike.
|
|
5
|
+
|
|
6
|
+
// No quoted profile parameter: a quote in a request header turns a browser's
|
|
7
|
+
// read into a preflighted one, and a plain Accept is enough for every server.
|
|
8
|
+
const ACCEPT = 'application/activity+json, application/ld+json, application/json;q=0.9';
|
|
9
|
+
const MAX_PAGES = 50;
|
|
10
|
+
|
|
11
|
+
export const categoryBase = (actorId) => String(actorId).replace(/ap\/actor$/u, '');
|
|
12
|
+
|
|
13
|
+
// Where a forum is read from: through a Gateway at `<origin>/u/<handle>/`, or
|
|
14
|
+
// a pod home given outright.
|
|
15
|
+
export const forumBase = ({ origin = null, handle = null, pod = null } = {}) => {
|
|
16
|
+
if (pod) return pod.endsWith('/') ? pod : pod + '/';
|
|
17
|
+
if (!origin || !handle) throw new Error('a forum is named by a handle at this site, or by its pod');
|
|
18
|
+
return `${origin}/u/${encodeURIComponent(handle)}/`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Where the page runs decides how a forum is named and where it is read from.
|
|
22
|
+
// At `bb.<gateway>` the forum is the first path segment and its record is read
|
|
23
|
+
// through the Gateway at `<gateway>`; anywhere else it is `?forum=<handle>`
|
|
24
|
+
// read through the page's own origin, or `?pod=<home>` read from the pod.
|
|
25
|
+
export function placeOf({ origin, pathname = '/', search = '' }) {
|
|
26
|
+
const params = new URLSearchParams(search);
|
|
27
|
+
const u = new URL(origin);
|
|
28
|
+
const own = /^bb\./u.test(u.host);
|
|
29
|
+
const front = own ? `${u.protocol}//${u.host.replace(/^bb\./u, '')}` : origin;
|
|
30
|
+
const fromPath = own ? decodeURIComponent(pathname.split('/').filter(Boolean)[0] || '') : '';
|
|
31
|
+
const handle = params.get('forum') || fromPath || null;
|
|
32
|
+
const pod = params.get('pod');
|
|
33
|
+
return { front, handle, pod, base: pod || handle ? forumBase({ origin: front, handle, pod }) : null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The name a cached copy is filed under (the same digest the host uses).
|
|
37
|
+
export async function cacheKey(postId) {
|
|
38
|
+
const bytes = new TextEncoder().encode(String(postId));
|
|
39
|
+
const d = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
40
|
+
return [...new Uint8Array(d)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `@name@host` for an actor id, for display: what the id says about itself.
|
|
44
|
+
export function authorLabel(actorId) {
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(actorId);
|
|
47
|
+
const name = u.pathname.replace(/\/$/u, '').split('/').filter(Boolean).filter(s => s !== 'ap' && s !== 'actor').pop() || u.host;
|
|
48
|
+
return `@${name}@${u.host}`;
|
|
49
|
+
} catch { return String(actorId); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session = null } = {}) {
|
|
53
|
+
// A topic's name and how many posts are in it, read once however many of
|
|
54
|
+
// its posts a feed carries.
|
|
55
|
+
const topicInfo = async (topicId, seen) => {
|
|
56
|
+
if (!topicId) return { name: null, total: 0 };
|
|
57
|
+
if (seen.has(topicId)) return seen.get(topicId);
|
|
58
|
+
const head = await get(topicId);
|
|
59
|
+
const info = { name: head?.name || null, total: Number(head?.totalItems) || 0 };
|
|
60
|
+
seen.set(topicId, info);
|
|
61
|
+
return info;
|
|
62
|
+
};
|
|
63
|
+
const get = async (url) => {
|
|
64
|
+
// A request that fails at the network level — a dropped connection, a
|
|
65
|
+
// document that is simply not there — reads the same as one that is not
|
|
66
|
+
// readable: nothing. Letting it throw meant one missing side document
|
|
67
|
+
// took the whole page down with it.
|
|
68
|
+
let r = null;
|
|
69
|
+
try { r = await f(url, { headers: { accept: ACCEPT } }); } catch { return null; }
|
|
70
|
+
// A members-only category answers 401 or 403 to the world. A reader who
|
|
71
|
+
// is signed in asks again as themselves, and the pod decides.
|
|
72
|
+
if (r && (r.status === 401 || r.status === 403) && session?.fetch) {
|
|
73
|
+
const mine = await session.fetch(url, { headers: { accept: ACCEPT } }).catch(() => null);
|
|
74
|
+
if (mine?.ok) { try { return await mine.json(); } catch { return null; } }
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!r || !r.ok) return null;
|
|
78
|
+
try { return await r.json(); } catch { return null; }
|
|
79
|
+
};
|
|
80
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
81
|
+
const pages = async (head, { forward = true } = {}) => {
|
|
82
|
+
const out = [];
|
|
83
|
+
let next = forward ? head?.first : head?.first; // both collections start at `first`
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
while (next && !seen.has(next) && out.length < MAX_PAGES) {
|
|
86
|
+
seen.add(next);
|
|
87
|
+
const page = await get(next);
|
|
88
|
+
if (!page) break;
|
|
89
|
+
out.push(page);
|
|
90
|
+
next = page.next;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
// The forum: its actor and its categories, each with its name, its
|
|
97
|
+
// summary and how many members it has.
|
|
98
|
+
async forum(base) {
|
|
99
|
+
const actor = await get(base + 'ap/actor');
|
|
100
|
+
if (!actor) return null;
|
|
101
|
+
const list = await get(base + 'ap/categories');
|
|
102
|
+
const categories = [];
|
|
103
|
+
for (const id of (list?.orderedItems || [])) {
|
|
104
|
+
const a = await get(id);
|
|
105
|
+
if (!a) continue;
|
|
106
|
+
const cbase = categoryBase(a.id || id);
|
|
107
|
+
const followers = await get(cbase + 'ap/followers');
|
|
108
|
+
categories.push({
|
|
109
|
+
id: a.id || id, base: cbase, slug: a.preferredUsername || null,
|
|
110
|
+
name: a.name || a.preferredUsername || id, summary: a.summary || null,
|
|
111
|
+
// Open or private, as the forum itself says on the category's
|
|
112
|
+
// actor. A private category approves each join, which is what AS2
|
|
113
|
+
// spells `manuallyApprovesFollowers`.
|
|
114
|
+
private: a.manuallyApprovesFollowers === true,
|
|
115
|
+
members: Number(followers?.totalItems) || 0,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Who runs it (FEP-baf5), each with the page a person is sent to.
|
|
119
|
+
const admins = [];
|
|
120
|
+
for (const id of ((await get(base + 'ap/administrators'))?.orderedItems || []).map(idOf).filter(Boolean)) {
|
|
121
|
+
const a = await get(id);
|
|
122
|
+
admins.push({
|
|
123
|
+
id,
|
|
124
|
+
handle: a?.preferredUsername && a?.id ? `@${a.preferredUsername}@${new URL(a.id).host}` : authorLabel(id),
|
|
125
|
+
url: (typeof a?.url === 'string' && a.url) || id,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const heartbeat = await get(base + 'ap/heartbeat');
|
|
129
|
+
return { id: actor.id, name: actor.name || actor.preferredUsername, summary: actor.summary || null,
|
|
130
|
+
handle: actor.preferredUsername || null, categories, admins, lastHosted: heartbeat?.at || null };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// A category's topics, newest first, with each topic's head.
|
|
134
|
+
async topics(cbase) {
|
|
135
|
+
const head = await get(cbase + 'ap/topics');
|
|
136
|
+
if (!head) return { total: 0, topics: [] };
|
|
137
|
+
const ps = await pages(head);
|
|
138
|
+
const topics = [];
|
|
139
|
+
for (const page of ps) {
|
|
140
|
+
for (const id of (page.orderedItems || [])) {
|
|
141
|
+
const t = await get(idOf(id));
|
|
142
|
+
if (t) topics.push({ id: t.id, name: t.name || '', count: Number(t.totalItems) || 0, published: t.published || null, updated: t.updated || t.published || null });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { total: Number(head.totalItems) || topics.length, topics };
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
// One topic: its head and every post id in order.
|
|
149
|
+
async topic(topicId) {
|
|
150
|
+
const head = await get(topicId);
|
|
151
|
+
if (!head) return null;
|
|
152
|
+
const ps = await pages(head);
|
|
153
|
+
const posts = ps.flatMap(p => (p.orderedItems || []).map(idOf)).filter(Boolean);
|
|
154
|
+
return { id: head.id, name: head.name || '', category: idOf(head.attributedTo) || null,
|
|
155
|
+
closed: head.closed || null,
|
|
156
|
+
total: Number(head.totalItems) || posts.length, published: head.published || null, updated: head.updated || null, posts };
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// The author's card, when the forum kept one: handle, name, picture.
|
|
160
|
+
async author(cbase, actorId) {
|
|
161
|
+
const card = await get(cbase + 'ap/cache/' + await cacheKey(actorId));
|
|
162
|
+
if (!card) return null;
|
|
163
|
+
let host = '';
|
|
164
|
+
try { host = new URL(card.id || actorId).host; } catch { /* unlabelled */ }
|
|
165
|
+
return {
|
|
166
|
+
id: card.id || actorId, name: card.name || card.preferredUsername || null,
|
|
167
|
+
handle: card.preferredUsername && host ? `@${card.preferredUsername}@${host}` : authorLabel(actorId),
|
|
168
|
+
// The page a person is sent to is the one the actor names. Its id is a
|
|
169
|
+
// document for machines, so it is not offered in its place.
|
|
170
|
+
icon: card.icon?.url || null, url: (typeof card.url === 'string' && card.url) || null,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// Somebody the forum has kept nothing about — a moderator who has not
|
|
175
|
+
// posted, say. Their own actor is what the page has to go on.
|
|
176
|
+
async actorCard(actorId) {
|
|
177
|
+
const doc = await get(actorId);
|
|
178
|
+
if (!doc?.id) return null;
|
|
179
|
+
let host = '';
|
|
180
|
+
try { host = new URL(doc.id).host; } catch { /* unlabelled */ }
|
|
181
|
+
const icon = typeof doc.icon === 'string' ? doc.icon : doc.icon?.url;
|
|
182
|
+
return {
|
|
183
|
+
id: doc.id, name: doc.name || doc.preferredUsername || null,
|
|
184
|
+
handle: doc.preferredUsername && host ? `@${doc.preferredUsername}@${host}` : authorLabel(actorId),
|
|
185
|
+
icon: icon || null, url: (typeof doc.url === 'string' && doc.url) || null,
|
|
186
|
+
// Where a message for them is handed in.
|
|
187
|
+
inbox: (typeof doc.inbox === 'string' && doc.inbox) || doc.endpoints?.sharedInbox || null,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// The handle an actor says is its own. A name read off an address is a
|
|
192
|
+
// guess, right only where the address happens to spell the name; a server
|
|
193
|
+
// that will not hand out its actors unasked leaves the guess standing.
|
|
194
|
+
async actorHandle(actorId) {
|
|
195
|
+
const doc = await get(actorId);
|
|
196
|
+
if (!doc?.preferredUsername) return null;
|
|
197
|
+
try { return `@${doc.preferredUsername}@${new URL(doc.id || actorId).host}`; } catch { return null; }
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// The forum's newest posts, across every category: the index names the
|
|
201
|
+
// forum's own copies, so one fetch of each tells who wrote it, when, and
|
|
202
|
+
// which topic and category it belongs to.
|
|
203
|
+
async latest(base, { limit = 30, from = 0 } = {}) {
|
|
204
|
+
const head = await get(base + 'ap/latest');
|
|
205
|
+
const all = (head?.orderedItems || []).map(idOf).filter(Boolean);
|
|
206
|
+
const ids = all.slice(from, from + limit);
|
|
207
|
+
const out = [];
|
|
208
|
+
const named = new Map();
|
|
209
|
+
for (const url of ids) {
|
|
210
|
+
const copy = await get(url);
|
|
211
|
+
if (!copy || copy.type === 'Tombstone') continue;
|
|
212
|
+
out.push({
|
|
213
|
+
id: copy.id || url,
|
|
214
|
+
|
|
215
|
+
author: idOf([].concat(copy.attributedTo || [])[0]) || null,
|
|
216
|
+
name: copy.name || null,
|
|
217
|
+
content: typeof copy.content === 'string' ? copy.content : '',
|
|
218
|
+
published: copy.published || null,
|
|
219
|
+
topic: idOf(copy.context) || null,
|
|
220
|
+
inReplyTo: idOf(copy.inReplyTo) || null,
|
|
221
|
+
// Answers to THIS post, as the forum counted them.
|
|
222
|
+
replies: Number(copy.replies?.totalItems) || 0,
|
|
223
|
+
likes: Number(copy.likes?.totalItems) || 0,
|
|
224
|
+
// The forum's own count, carried in the copy beside `likes`.
|
|
225
|
+
dislikes: Number(copy.dislikes?.totalItems) || 0,
|
|
226
|
+
category: idOf([].concat(copy.audience || [])[0]) || null,
|
|
227
|
+
page: [].concat(copy.url || []).map(u => (typeof u === 'string' ? u : null)).find(Boolean) || null,
|
|
228
|
+
});
|
|
229
|
+
const info = await topicInfo(out[out.length - 1].topic, named);
|
|
230
|
+
out[out.length - 1].topicName = info.name;
|
|
231
|
+
// Replies: everything in the topic but the post that opened it.
|
|
232
|
+
out[out.length - 1].topicTotal = info.total;
|
|
233
|
+
// What the index shows: how many replies the TOPIC has had. The
|
|
234
|
+
// post's own answers are on `replies`, for whatever wants them.
|
|
235
|
+
out[out.length - 1].topicReplies = Math.max(0, info.total - 1);
|
|
236
|
+
}
|
|
237
|
+
// What is left behind this page, so a reader can ask for it.
|
|
238
|
+
out.more = Math.max(0, all.length - (from + ids.length));
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// The topics a category has pinned (its featured collection): what a
|
|
243
|
+
// reader should see first, whatever else has been said since.
|
|
244
|
+
async featured(cbase) {
|
|
245
|
+
const c = await get(cbase + 'ap/featured');
|
|
246
|
+
return (c?.orderedItems || []).map(idOf).filter(Boolean);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
// Whether this reader may read a category's posts at all: a members-only
|
|
250
|
+
// one refuses the world, and its topics then look like none.
|
|
251
|
+
async canRead(cbase) {
|
|
252
|
+
const r = await f(cbase + 'ap/topics', { headers: { accept: ACCEPT } }).catch(() => null);
|
|
253
|
+
if (r && (r.status === 401 || r.status === 403)) {
|
|
254
|
+
if (!session?.fetch) return false;
|
|
255
|
+
const mine = await session.fetch(cbase + 'ap/topics', { headers: { accept: ACCEPT } }).catch(() => null);
|
|
256
|
+
return !!mine?.ok;
|
|
257
|
+
}
|
|
258
|
+
return !!r?.ok;
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
// Who moderates a category. The category says so itself (FEP-1b12), which
|
|
262
|
+
// is what lets a page show a moderator their own buttons.
|
|
263
|
+
async moderators(cbase) {
|
|
264
|
+
const c = await get(cbase + 'ap/moderators');
|
|
265
|
+
return (c?.orderedItems || []).map(idOf).filter(Boolean);
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
// The readable copy of a post, or null when the forum holds none.
|
|
269
|
+
async post(cbase, postId) {
|
|
270
|
+
const at = cbase + 'ap/cache/' + await cacheKey(postId);
|
|
271
|
+
const copy = await get(at);
|
|
272
|
+
if (!copy) return null;
|
|
273
|
+
// `url` is where a PERSON reads this post; `id` is where a server
|
|
274
|
+
// fetches it. They are the same on some servers and never on a pod.
|
|
275
|
+
const page = [].concat(copy.url || [])
|
|
276
|
+
.map(u => (typeof u === 'string' ? u : (u?.mediaType === 'text/html' ? u.href : null)))
|
|
277
|
+
.find(u => typeof u === 'string' && /^https?:/u.test(u)) || null;
|
|
278
|
+
return {
|
|
279
|
+
id: copy.id || postId, type: copy.type || 'Note', gone: copy.type === 'Tombstone', page,
|
|
280
|
+
author: idOf([].concat(copy.attributedTo || [])[0]) || null,
|
|
281
|
+
name: copy.name || null, content: typeof copy.content === 'string' ? copy.content : '',
|
|
282
|
+
// What its author typed, when they said so: an edit reopens this
|
|
283
|
+
// rather than guessing it back out of the HTML.
|
|
284
|
+
source: copy.source?.mediaType === 'text/markdown' && typeof copy.source?.content === 'string' ? copy.source.content : null,
|
|
285
|
+
published: copy.published || null, updated: copy.updated || null, inReplyTo: idOf(copy.inReplyTo) || null,
|
|
286
|
+
// Which topic the forum placed it in: what a Delete has to name as
|
|
287
|
+
// the place the post is being taken out of.
|
|
288
|
+
topic: idOf(copy.context) || null,
|
|
289
|
+
likes: Number(copy.likes?.totalItems) || 0,
|
|
290
|
+
dislikes: Number(copy.dislikes?.totalItems) || 0,
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
package/site/seen.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// seen.mjs — what this reader has already read, kept in their own browser.
|
|
2
|
+
//
|
|
3
|
+
// A forum's record is public and its readers are mostly anonymous, so there is
|
|
4
|
+
// nowhere on the pod to write "Mei has read this" and no right to. The mark is
|
|
5
|
+
// the reader's own: one entry per topic, the time of the newest post that was
|
|
6
|
+
// on the page when they last opened it, in this browser's storage and nowhere
|
|
7
|
+
// else. Nothing here is sent anywhere.
|
|
8
|
+
|
|
9
|
+
const KEY = (forum) => 'bb:seen:' + forum;
|
|
10
|
+
// How many topics a reader's mark is kept for. Beyond this the oldest marks
|
|
11
|
+
// go, and the forum's first-sight time below covers what falls off.
|
|
12
|
+
const MAX = 300;
|
|
13
|
+
|
|
14
|
+
const at = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
|
|
15
|
+
|
|
16
|
+
export function readState({ storage, forum, now = () => new Date().toISOString() }) {
|
|
17
|
+
const key = KEY(forum);
|
|
18
|
+
let rec;
|
|
19
|
+
try { rec = JSON.parse(storage.getItem(key) || 'null'); } catch { rec = null; }
|
|
20
|
+
if (!rec || typeof rec !== 'object') rec = null;
|
|
21
|
+
const save = () => { try { storage.setItem(key, JSON.stringify(rec)); } catch { /* full or blocked */ } };
|
|
22
|
+
// A forum a person has never opened is not a hundred unread topics; it is a
|
|
23
|
+
// forum they are arriving at. The first visit reads everything before it.
|
|
24
|
+
if (!rec) { rec = { since: now(), topics: {} }; save(); }
|
|
25
|
+
if (!rec.topics || typeof rec.topics !== 'object') rec.topics = {};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
since: rec.since,
|
|
29
|
+
|
|
30
|
+
// A post is new when it arrived after this reader last had its topic open
|
|
31
|
+
// — or, for a topic they have never opened, after they first saw the forum.
|
|
32
|
+
isNew(topicId, published) {
|
|
33
|
+
const when = at(published);
|
|
34
|
+
if (when === null) return false;
|
|
35
|
+
const mark = at((topicId && rec.topics[topicId]) || rec.since);
|
|
36
|
+
return mark === null ? false : when > mark;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Opening a topic reads it, up to the newest post that was in it.
|
|
40
|
+
markRead(topicId, newest) {
|
|
41
|
+
const when = at(newest);
|
|
42
|
+
if (!topicId || when === null) return;
|
|
43
|
+
const had = at(rec.topics[topicId]);
|
|
44
|
+
if (had !== null && had >= when) return;
|
|
45
|
+
rec.topics[topicId] = new Date(when).toISOString();
|
|
46
|
+
const ids = Object.keys(rec.topics);
|
|
47
|
+
if (ids.length > MAX) {
|
|
48
|
+
ids.sort((a, b) => (at(rec.topics[a]) || 0) - (at(rec.topics[b]) || 0));
|
|
49
|
+
for (const id of ids.slice(0, ids.length - MAX)) delete rec.topics[id];
|
|
50
|
+
}
|
|
51
|
+
save();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// The newest post in a list, which is what opening it reads up to.
|
|
55
|
+
newest(posts) {
|
|
56
|
+
let best = null;
|
|
57
|
+
for (const p of posts || []) {
|
|
58
|
+
const t = at(p?.published);
|
|
59
|
+
if (t !== null && (best === null || t > best)) best = t;
|
|
60
|
+
}
|
|
61
|
+
return best === null ? null : new Date(best).toISOString();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/access.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// access.mjs — who may read a private category, and what follows from that:
|
|
2
|
+
// the reader list its members' own pods copy into their access rules, reading
|
|
3
|
+
// a member's post back as the forum rather than as a federating server, and
|
|
4
|
+
// letting go of a follower the category could never let read.
|
|
5
|
+
//
|
|
6
|
+
// Each takes the forum as its first argument, the way settings.mjs and
|
|
7
|
+
// moderation.mjs do.
|
|
8
|
+
|
|
9
|
+
// Who may read a private category: the WebIDs the forum was given for it,
|
|
10
|
+
// and its moderators'. Null means the category is OPEN, which is the usual
|
|
11
|
+
// case and the only thing that makes its pages public. A private category
|
|
12
|
+
// with nobody named yet returns an empty list, not null: the pod keeps
|
|
13
|
+
// everyone out rather than letting everyone in.
|
|
14
|
+
export function membersOf(forum, cat) {
|
|
15
|
+
const closed = (forum.config.membersOnly || []).includes(cat.slug);
|
|
16
|
+
if (!closed) return null;
|
|
17
|
+
const named = forum.config.memberWebIds || {};
|
|
18
|
+
return [...new Set([...(named[cat.slug] || []), ...(forum.config.moderatorWebIds || [])])];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Who a private category's posts are written for: its members, its
|
|
22
|
+
// moderators, and the forum itself. The forum is on the list because it
|
|
23
|
+
// fetches every post back from its author's pod to check who wrote it
|
|
24
|
+
// (FEP-fe34), and a rule that left it out would refuse the forum the post it
|
|
25
|
+
// was just handed. Null for an open category.
|
|
26
|
+
export function readersOf(forum, cat) {
|
|
27
|
+
const members = membersOf(forum, cat);
|
|
28
|
+
if (!members) return null;
|
|
29
|
+
const mine = forum.remote?.webId;
|
|
30
|
+
return [...new Set([...members, ...(mine ? [mine] : [])])];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One document read as the forum itself rather than as a federating server.
|
|
34
|
+
// Straight to the session: a foreign pod's answer is not this pod's business
|
|
35
|
+
// and must not be gated behind its cooldown or run through its url map.
|
|
36
|
+
export async function podFetchAP(forum, url) {
|
|
37
|
+
const send = forum.remote?.session?.fetch
|
|
38
|
+
? (u, i) => forum.remote.session.fetch(u, i)
|
|
39
|
+
: (u, i) => forum.remote.fetch(u, i);
|
|
40
|
+
try {
|
|
41
|
+
const r = await send(url, { headers: { accept: 'application/activity+json, application/ld+json, application/json;q=0.9' } });
|
|
42
|
+
if (!r?.ok) return null;
|
|
43
|
+
const doc = await r.json().catch(() => null);
|
|
44
|
+
return doc && typeof doc === 'object' ? doc : null;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
forum.log(`reading ${url} as the forum: ${e.message}`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A private category carries only to people its members' pods can let read,
|
|
52
|
+
// and that means a WebID. Anyone else following it — every follower it
|
|
53
|
+
// gathered while it was open, on Mastodon or anywhere else without one — is
|
|
54
|
+
// let go and told, rather than being sent posts they are refused.
|
|
55
|
+
export async function dropUnreadableFollowers(forum, cat) {
|
|
56
|
+
if (!(forum.config.membersOnly || []).includes(cat.slug)) return 0;
|
|
57
|
+
const contacts = cat.store.getContacts();
|
|
58
|
+
const dropped = [];
|
|
59
|
+
for (const f of [...contacts.followers]) {
|
|
60
|
+
const webid = await forum.webIdOf(f.actor).catch(() => null);
|
|
61
|
+
if (webid) continue;
|
|
62
|
+
dropped.push(f);
|
|
63
|
+
contacts.followers = contacts.followers.filter(x => x.actor !== f.actor);
|
|
64
|
+
}
|
|
65
|
+
if (!dropped.length) return 0;
|
|
66
|
+
cat.store.setContacts(contacts);
|
|
67
|
+
await cat.publisher.publishCollections({ followers: true }).catch(() => {});
|
|
68
|
+
const wire = await import('fedipod/core/wire.mjs');
|
|
69
|
+
for (const f of dropped) {
|
|
70
|
+
const inbox = f.sharedInbox || f.inbox;
|
|
71
|
+
if (!inbox) continue;
|
|
72
|
+
await cat.deliverer.deliver(inbox, wire.rejectActivity({
|
|
73
|
+
urls: cat.urls, serial: Date.now(),
|
|
74
|
+
followActivity: { id: f.followId || undefined, type: 'Follow', actor: f.actor, object: cat.urls.actor },
|
|
75
|
+
})).catch(e => forum.log(`telling ${f.actor} the category is private: ${e.message}`));
|
|
76
|
+
}
|
|
77
|
+
forum.log(`${cat.slug} is private: ${dropped.length} follower(s) with no WebID let go`);
|
|
78
|
+
return dropped.length;
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// credential.mjs — a client credential for the forum's pod, minted at the
|
|
2
|
+
// pod's server by the account that owns it and saved where the forum reads
|
|
3
|
+
// it. Nothing is written to the pod. The password is asked at the terminal
|
|
4
|
+
// and never written anywhere.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
import { mintCredential } from 'fedipod/remote';
|
|
10
|
+
|
|
11
|
+
const askHidden = (prompt) => new Promise((resolve) => {
|
|
12
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
const onData = (c) => { if (String(c) !== '\n' && String(c) !== '\r') { readline.moveCursor(process.stdout, -1, 0); process.stdout.write('*'); } };
|
|
14
|
+
process.stdin.on('data', onData);
|
|
15
|
+
rl.question(prompt, (a) => { process.stdin.off('data', onData); rl.close(); process.stdout.write('\n'); resolve(a); });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mint the credential and write DIR/credential.json. `issuer` defaults to
|
|
20
|
+
* the pod server's origin: for a subdomained pod, one label up from the pod.
|
|
21
|
+
* Refuses to overwrite: a minted credential is shown once.
|
|
22
|
+
*/
|
|
23
|
+
export async function mintForumCredential({ email, pod, home, issuer = null, root = 'fedipod-bb/', password = null, log = console.log }) {
|
|
24
|
+
const base = String(pod).replace(/\/*$/u, '/');
|
|
25
|
+
const iss = (issuer || new URL(base).origin.replace(/^https?:\/\/[^.]+\./u, (m) => m.slice(0, m.indexOf('//') + 2))).replace(/\/+$/u, '');
|
|
26
|
+
const webId = base + 'profile/card#me';
|
|
27
|
+
const file = path.join(home, 'credential.json');
|
|
28
|
+
if (fs.existsSync(file)) throw new Error(`${file} exists — a minted credential is shown once; nothing was done`);
|
|
29
|
+
log(`pod ${base}\nwebId ${webId}\nissuer ${iss}`);
|
|
30
|
+
const pw = password || process.env.AP_PASSWORD || await askHidden(`password for ${email} at ${iss}: `);
|
|
31
|
+
if (!pw) throw new Error('no password given; nothing was done');
|
|
32
|
+
const credential = await mintCredential({ origin: iss, email, password: pw, webId, name: 'fedipod-bb' });
|
|
33
|
+
const rec = { ...credential, remotePod: base, root: root.replace(/^\/+|\/+$/gu, '') + '/', createdAt: new Date().toISOString() };
|
|
34
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
35
|
+
fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
|
|
36
|
+
return file;
|
|
37
|
+
}
|