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/pod.mjs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// pod.mjs — posting to the forum from the reader's own Solid pod, in this
|
|
2
|
+
// page. The reader signs in to their pod (Solid-OIDC, the browser build's
|
|
3
|
+
// own session module), the post is written into their pod as their own
|
|
4
|
+
// document, and the activity announcing it is appended to the forum's inbox.
|
|
5
|
+
// Nothing is posted from the forum's account and no key is needed here: the
|
|
6
|
+
// forum's host fetches the post back from the reader's pod, which is where
|
|
7
|
+
// the truth of who wrote it lives (FEP-fe34).
|
|
8
|
+
|
|
9
|
+
import { beginLogin, completeLogin, getSession, signOut as endSession } from './oidc-session.mjs';
|
|
10
|
+
import { toHtml } from './markdown.mjs';
|
|
11
|
+
import * as priv from './private.mjs';
|
|
12
|
+
|
|
13
|
+
const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
14
|
+
const AS = 'https://www.w3.org/ns/activitystreams';
|
|
15
|
+
|
|
16
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 48);
|
|
17
|
+
const stamp = () => new Date().toISOString().slice(0, 10);
|
|
18
|
+
|
|
19
|
+
// Where this reader's own account keeps its things. Two addresses for one
|
|
20
|
+
// post: it is WRITTEN into their pod, and it is PUBLISHED under the address
|
|
21
|
+
// their actor answers at — the same one for a plain pod, the front's for an
|
|
22
|
+
// account fronted at a Gateway. The two must agree, or a receiving server is
|
|
23
|
+
// right to refuse a post whose author lives on another host (FEP-fe34).
|
|
24
|
+
// A picture goes into the reader's own pod, in the container their account
|
|
25
|
+
// already keeps media in — public to read, like the posts that will show it.
|
|
26
|
+
export async function upload({ actor, podHome, file }) {
|
|
27
|
+
const s = await getSession();
|
|
28
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
29
|
+
if (!/^image\//u.test(file.type)) throw new Error('that is not an image');
|
|
30
|
+
if (file.size > 5 * 1024 * 1024) throw new Error('images are limited to 5 MB');
|
|
31
|
+
const home = homeOf(actor, podHome);
|
|
32
|
+
const ext = (file.name.match(/\.[a-z0-9]{1,5}$/iu) || [''])[0].toLowerCase() || '.img';
|
|
33
|
+
const at = `${home}ap/media/${stamp()}-${crypto.randomUUID().slice(0, 8)}${ext}`;
|
|
34
|
+
const put = await s.fetch(at, { method: 'PUT', headers: { 'content-type': file.type }, body: file });
|
|
35
|
+
if (!put.ok) throw new Error(`your pod refused the image (HTTP ${put.status})`);
|
|
36
|
+
// Published under the address the account answers at, like its posts.
|
|
37
|
+
return faceOf(actor, podHome) + at.slice(home.length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const placeOf = (actor, podHome) => ({
|
|
41
|
+
actor,
|
|
42
|
+
id: (name) => faceOf(actor, podHome) + 'ap/notes/' + name,
|
|
43
|
+
at: (name) => homeOf(actor, podHome) + 'ap/notes/' + name,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The face an account publishes under, and the pod it is actually written on.
|
|
47
|
+
// A post in a private category does not live in `ap/notes/`, so the address it
|
|
48
|
+
// is written at is worked out from the whole id rather than from its last
|
|
49
|
+
// segment: the two differ by the container, not only by the name.
|
|
50
|
+
//
|
|
51
|
+
// `actor` is a real FediPod actor (…/ap/actor) for an account that has one —
|
|
52
|
+
// the face is that URL with the suffix stripped, which may differ from where
|
|
53
|
+
// the pod actually holds things (a Gateway-fronted account). A reader who
|
|
54
|
+
// signed in from a bare WebID, with no FediPod account at all, HAS no such
|
|
55
|
+
// front: their face and their pod are the same address, `podHome` — resolved
|
|
56
|
+
// at sign-in from their pod's own storage root (see `resolveWebId`).
|
|
57
|
+
const faceOf = (actor, podHome = null) => {
|
|
58
|
+
if (/ap\/actor$/u.test(actor)) return actor.replace(/ap\/actor$/u, '');
|
|
59
|
+
if (podHome) return podHome.replace(/\/?$/u, '/');
|
|
60
|
+
throw new Error('no address to publish under — pass podHome for an account with no ap/actor');
|
|
61
|
+
};
|
|
62
|
+
const homeOf = (actor, podHome) => (podHome ? podHome.replace(/\/?$/u, '/') : faceOf(actor));
|
|
63
|
+
export const podUrlOf = (actor, podHome, id) => {
|
|
64
|
+
const face = faceOf(actor, podHome);
|
|
65
|
+
return String(id).startsWith(face) ? homeOf(actor, podHome) + String(id).slice(face.length) : String(id);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Where a post goes and who it is addressed to. An open category: the public
|
|
69
|
+
// notes container, addressed to the world and to the category. A private one:
|
|
70
|
+
// a container of its own on the author's pod, readable by the people the
|
|
71
|
+
// category names, addressed to the category's members and to nobody else —
|
|
72
|
+
// not to the public, and not to the author's own followers.
|
|
73
|
+
//
|
|
74
|
+
// Every failure here throws, so a post never lands in the open because the
|
|
75
|
+
// private half did not work.
|
|
76
|
+
async function placeAndAudience(s, { actor, podHome, category, categoryBase, isPrivate }) {
|
|
77
|
+
if (!isPrivate) return { where: 'ap/notes/', to: [PUBLIC, category] };
|
|
78
|
+
if (!categoryBase) throw new Error('this category was not read from the forum, so nothing was posted');
|
|
79
|
+
const readers = await priv.readersOf(categoryBase, s);
|
|
80
|
+
if (!readers) throw new Error('the forum does not say who may read this category — nothing was posted');
|
|
81
|
+
if (!readers.length) throw new Error('you are not a member of this category');
|
|
82
|
+
const where = priv.placeFor(category);
|
|
83
|
+
await priv.prepare(s, homeOf(actor, podHome) + where, s.webId, readers);
|
|
84
|
+
return { where, to: [categoryBase + 'ap/followers'] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// What the forum answers as, on its own pod: the page reads a category
|
|
88
|
+
// through the Gateway, whose ids are the front's, but a post is delivered to
|
|
89
|
+
// the pod itself.
|
|
90
|
+
export async function podInboxOf(categoryActorId, { front = null, handle = null, fetch: f = globalThis.fetch.bind(globalThis) } = {}) {
|
|
91
|
+
// Through a Gateway the category's actor names the Gateway's own door, which
|
|
92
|
+
// takes signed mail between servers. A post made here is the reader's own,
|
|
93
|
+
// so it goes to the pod inbox the Gateway names when asked.
|
|
94
|
+
if (front && handle && categoryActorId.startsWith(front)) {
|
|
95
|
+
const said = await f(`${front}/api/server?host=${encodeURIComponent(new URL(front).host)}&handle=${encodeURIComponent(handle)}`,
|
|
96
|
+
{ headers: { accept: 'application/json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
97
|
+
if (said?.inbox) return said.inbox;
|
|
98
|
+
}
|
|
99
|
+
const doc = await f(categoryActorId, { headers: { accept: 'application/activity+json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
100
|
+
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox;
|
|
101
|
+
return typeof inbox === 'string' ? inbox : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const session = getSession;
|
|
105
|
+
export const signOut = endSession;
|
|
106
|
+
|
|
107
|
+
// Send them to their pod to sign in. `issuer` came from the Gateway, which
|
|
108
|
+
// knows the pod behind their handle.
|
|
109
|
+
export async function signIn({ issuer, redirectUri }) {
|
|
110
|
+
const { authorizationUrl } = await beginLogin({ issuer, redirectUri });
|
|
111
|
+
return authorizationUrl;
|
|
112
|
+
}
|
|
113
|
+
export async function complete(currentUrl) {
|
|
114
|
+
await completeLogin({ currentUrl });
|
|
115
|
+
return getSession();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const STORAGE = 'http://www.w3.org/ns/pim/space#storage';
|
|
119
|
+
const OIDC_ISSUER = 'http://www.w3.org/ns/solid/terms#oidcIssuer';
|
|
120
|
+
const jsonld = (url, f) => f(url, { headers: { accept: 'application/ld+json' } })
|
|
121
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
122
|
+
const nodesOf = (doc) => (Array.isArray(doc) ? doc : (doc?.['@graph'] || (doc ? [doc] : [])));
|
|
123
|
+
const firstOf = (v) => [].concat(v || []).map(x => (typeof x === 'string' ? x : x?.['@id'])).find(Boolean) || null;
|
|
124
|
+
|
|
125
|
+
// A bare WebID, read for its own login provider and its own storage root —
|
|
126
|
+
// no Fediverse actor anywhere in this. A pod names both on the WebID profile
|
|
127
|
+
// itself (Solid's own vocabulary, not FediPod's): `solid:oidcIssuer` says
|
|
128
|
+
// where to sign in, `pim:storage` says where the account keeps things. A pod
|
|
129
|
+
// that names no storage falls back to its own origin — every Solid server
|
|
130
|
+
// lets its owner write under their own root, so this is never wrong, only
|
|
131
|
+
// sometimes less tidy than a pod's chosen container.
|
|
132
|
+
//
|
|
133
|
+
// What is written there lives under `fedipod-bb/`, not loose at the storage
|
|
134
|
+
// root — its OWN container, named for the one app that wrote it, since there
|
|
135
|
+
// is no FediPod account here to own a `fedipod/` of its own. Everything
|
|
136
|
+
// under it keeps the exact shape a real account's `fedipod/` would have
|
|
137
|
+
// (`ap/notes/`, `ap/private/<host>-<category>/`, from `placeOf`/`placeFor`
|
|
138
|
+
// unchanged) — on purpose, so if this reader later gets a full FediPod
|
|
139
|
+
// account, moving to it is copying `fedipod-bb/*` to `fedipod/*` unchanged,
|
|
140
|
+
// not re-mapping anything.
|
|
141
|
+
export async function resolveWebId(webId, f = globalThis.fetch.bind(globalThis)) {
|
|
142
|
+
const doc = await jsonld(webId, f);
|
|
143
|
+
const nodes = nodesOf(doc);
|
|
144
|
+
const me = nodes.find(n => n?.['@id'] === webId) || {};
|
|
145
|
+
const issuer = firstOf(me[OIDC_ISSUER]);
|
|
146
|
+
if (!issuer) throw new Error(`${webId} names no Solid login provider (solid:oidcIssuer)`);
|
|
147
|
+
const storage = firstOf(me[STORAGE]);
|
|
148
|
+
const base = (storage || new URL('/', webId).href).replace(/\/?$/u, '/');
|
|
149
|
+
return { issuer, storageRoot: `${base}fedipod-bb/` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The no-fedi-account door: typed a WebID, not a Fediverse handle. Nothing
|
|
153
|
+
// here asks the Gateway or WebFinger — a WebID proves itself, or it does not
|
|
154
|
+
// sign in at all.
|
|
155
|
+
export async function signInWithWebId(webId, redirectUri) {
|
|
156
|
+
const { issuer, storageRoot } = await resolveWebId(webId);
|
|
157
|
+
const { authorizationUrl } = await beginLogin({ issuer, redirectUri });
|
|
158
|
+
return { authorizationUrl, storageRoot };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The post itself: a document in their pod, then a Create appended to the
|
|
162
|
+
// forum's inbox. A post has no title of its own; when it opens a topic, the
|
|
163
|
+
// activity that opens it carries the TOPIC's name, which is a different
|
|
164
|
+
// thing and belongs to the topic.
|
|
165
|
+
export async function post({ actor, podHome, category, categoryBase = null, categoryHandle, isPrivate = false,
|
|
166
|
+
inbox, topic = '', title = '', text, inReplyTo = null, context = null }) {
|
|
167
|
+
const s = await getSession();
|
|
168
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
169
|
+
const { where, to } = await placeAndAudience(s, { actor, podHome, category, categoryBase, isPrivate });
|
|
170
|
+
const name = `${stamp()}-${slug(title) || slug(topic) || 'post'}-${crypto.randomUUID().slice(0, 8)}`;
|
|
171
|
+
const id = faceOf(actor, podHome) + where + name;
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
const body = htmlOf(text);
|
|
174
|
+
const note = {
|
|
175
|
+
'@context': AS,
|
|
176
|
+
id,
|
|
177
|
+
// A post with a title of its own is a Page, as Lemmy's own posts are;
|
|
178
|
+
// Mastodon converts a Page and an Article alike to the title and a link.
|
|
179
|
+
// The title is the POST's, and says nothing about what its topic is called.
|
|
180
|
+
type: title ? 'Page' : 'Note',
|
|
181
|
+
attributedTo: actor,
|
|
182
|
+
...(title ? { name: String(title).slice(0, 200) } : {}),
|
|
183
|
+
// The category is named in audience, in to, and as a Mention tag — the
|
|
184
|
+
// three places a receiving server reads. Not in the words.
|
|
185
|
+
content: body,
|
|
186
|
+
source: sourceOf(text),
|
|
187
|
+
published: now,
|
|
188
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
189
|
+
...(context ? { context, audience: category } : { audience: category }),
|
|
190
|
+
to,
|
|
191
|
+
cc: [],
|
|
192
|
+
tag: [{ type: 'Mention', href: category, ...(categoryHandle ? { name: categoryHandle } : {}) }],
|
|
193
|
+
};
|
|
194
|
+
// The type it is STORED as decides what a reader may ask for: a pod will
|
|
195
|
+
// not convert one JSON flavour into another, and answers 501 to a server
|
|
196
|
+
// asking for ActivityPub over a document filed as plain JSON-LD.
|
|
197
|
+
const put = await s.fetch(homeOf(actor, podHome) + where + name, { method: 'PUT', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(note) });
|
|
198
|
+
if (!put.ok) throw new Error(`your pod refused the post (HTTP ${put.status})`);
|
|
199
|
+
const create = {
|
|
200
|
+
'@context': AS,
|
|
201
|
+
id: `${id}#create`,
|
|
202
|
+
type: 'Create',
|
|
203
|
+
actor,
|
|
204
|
+
published: now,
|
|
205
|
+
...(topic ? { name: String(topic).slice(0, 200) } : {}),
|
|
206
|
+
object: note,
|
|
207
|
+
to,
|
|
208
|
+
cc: [],
|
|
209
|
+
};
|
|
210
|
+
const sent = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(create) });
|
|
211
|
+
if (!sent.ok) throw new Error(`the forum did not take it (HTTP ${sent.status})`);
|
|
212
|
+
return { id, uri: id };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Joining: a category carries a member's posts, so the first post from a new
|
|
216
|
+
// reader is preceded by a Follow the category answers itself.
|
|
217
|
+
export async function edit({ actor, podHome, id, title = '', text, category, categoryBase = null, isPrivate = false, inbox }) {
|
|
218
|
+
const s = await getSession();
|
|
219
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
220
|
+
const now = new Date().toISOString();
|
|
221
|
+
const at = podUrlOf(actor, podHome, id);
|
|
222
|
+
// Read as the author: a post in a private category answers nobody else.
|
|
223
|
+
const was = await s.fetch(id, { headers: { accept: 'application/activity+json' } }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
224
|
+
// A change keeps the audience the post already had. Rewriting it from the
|
|
225
|
+
// category alone would turn a private post public on its way through an
|
|
226
|
+
// edit, which is the kind of quiet widening nothing here may do.
|
|
227
|
+
const to = was?.to || (isPrivate && categoryBase ? [categoryBase + 'ap/followers'] : [PUBLIC, category]);
|
|
228
|
+
const note = {
|
|
229
|
+
...(was || { '@context': AS, id, type: 'Note', attributedTo: actor, to }),
|
|
230
|
+
type: title ? 'Page' : 'Note',
|
|
231
|
+
content: htmlOf(text),
|
|
232
|
+
source: sourceOf(text),
|
|
233
|
+
updated: now,
|
|
234
|
+
};
|
|
235
|
+
// A title taken away is taken away, not left behind from the old copy.
|
|
236
|
+
if (title) note.name = String(title).slice(0, 200); else delete note.name;
|
|
237
|
+
const put = await s.fetch(at, { method: 'PUT', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(note) });
|
|
238
|
+
if (!put.ok) throw new Error(`your pod refused the change (HTTP ${put.status})`);
|
|
239
|
+
note.to = to;
|
|
240
|
+
const update = { '@context': AS, id: `${id}#update-${Date.now()}`, type: 'Update', actor, published: now,
|
|
241
|
+
object: note, to, cc: [] };
|
|
242
|
+
const sent = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(update) });
|
|
243
|
+
if (!sent.ok) throw new Error(`the forum did not take the change (HTTP ${sent.status})`);
|
|
244
|
+
return { id };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function remove({ actor, podHome, id, category, categoryBase = null, isPrivate = false, inbox }) {
|
|
248
|
+
const s = await getSession();
|
|
249
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
250
|
+
const now = new Date().toISOString();
|
|
251
|
+
const at = podUrlOf(actor, podHome, id);
|
|
252
|
+
const to = isPrivate && categoryBase ? [categoryBase + 'ap/followers'] : [PUBLIC, category];
|
|
253
|
+
const stone = { '@context': AS, id, type: 'Tombstone', formerType: 'Note', deleted: now };
|
|
254
|
+
const put = await s.fetch(at, { method: 'PUT', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(stone) });
|
|
255
|
+
if (!put.ok) throw new Error(`your pod refused the deletion (HTTP ${put.status})`);
|
|
256
|
+
const del = { '@context': AS, id: `${id}#delete-${Date.now()}`, type: 'Delete', actor, published: now,
|
|
257
|
+
object: id, to, cc: [] };
|
|
258
|
+
const sent = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(del) });
|
|
259
|
+
if (!sent.ok) throw new Error(`the forum was not told (HTTP ${sent.status})`);
|
|
260
|
+
return { id };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A post on somebody's timeline, sent the way their server will accept it.
|
|
264
|
+
//
|
|
265
|
+
// The page holds no signing key, and a fediverse server refuses an unsigned
|
|
266
|
+
// delivery — so the post is not delivered from here at all. It is handed to
|
|
267
|
+
// the sender's OWN door at their Gateway, proved with their pod login, and
|
|
268
|
+
// their own agent publishes it and signs the delivery.
|
|
269
|
+
//
|
|
270
|
+
// Two things follow from that, and the page says both before anything is
|
|
271
|
+
// typed: it only works for an account with a door at this Gateway, and it
|
|
272
|
+
// goes out when that account's agent next reads its inbox.
|
|
273
|
+
export async function postTo({ actor, podHome, handle, to, toHandle = '', front, text }) {
|
|
274
|
+
const s = await getSession();
|
|
275
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
276
|
+
const local = String(handle || '').replace(/^@/u, '').split('@')[0];
|
|
277
|
+
if (!front || !local) throw new Error('this page does not know which door to hand your post to');
|
|
278
|
+
const url = `${String(front).replace(/\/$/u, '')}/u/${encodeURIComponent(local)}/ap/outbox`;
|
|
279
|
+
const note = {
|
|
280
|
+
'@context': AS, type: 'Note',
|
|
281
|
+
content: htmlOf(text), source: sourceOf(text),
|
|
282
|
+
to: [PUBLIC, to], cc: [],
|
|
283
|
+
tag: [{ type: 'Mention', href: to, ...(toHandle ? { name: toHandle } : {}) }],
|
|
284
|
+
};
|
|
285
|
+
let sent;
|
|
286
|
+
try {
|
|
287
|
+
sent = await s.fetch(url, {
|
|
288
|
+
method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(note),
|
|
289
|
+
});
|
|
290
|
+
} catch (e) {
|
|
291
|
+
throw new Error(`${e.message} — handing it to your own door at ${new URL(url).host}`);
|
|
292
|
+
}
|
|
293
|
+
if (sent.status === 404) {
|
|
294
|
+
throw new Error(`${new URL(url).host} keeps no door for @${local} — this page can only post for an account fronted there`);
|
|
295
|
+
}
|
|
296
|
+
if (!sent.ok) {
|
|
297
|
+
const said = await sent.json().catch(() => null);
|
|
298
|
+
throw new Error(`your own door refused it (HTTP ${sent.status})${said?.error ? ` — ${said.error}` : ''}`);
|
|
299
|
+
}
|
|
300
|
+
return { accepted: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Reporting a post to the forum's moderators. A Flag names what is being
|
|
304
|
+
// reported and, when the reader says why, carries their words with it.
|
|
305
|
+
export async function report({ actor, object, category, inbox, why = '' }) {
|
|
306
|
+
const flag = { '@context': AS, id: `${actor}#flag-${Date.now()}`, type: 'Flag', actor,
|
|
307
|
+
object: [].concat(object), audience: category, to: [category],
|
|
308
|
+
...(why ? { content: String(why).slice(0, 2000) } : {}) };
|
|
309
|
+
const r = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(flag) });
|
|
310
|
+
if (!r.ok) throw new Error(`the forum did not take the report (HTTP ${r.status})`);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A moderator's ask, published at the moderator's own address before it is
|
|
315
|
+
// sent: the forum fetches it back there and believes it because only its
|
|
316
|
+
// author could have put it there (FEP-fe34). A delivery alone proves nothing.
|
|
317
|
+
export async function moderate({ actor, podHome, inbox, activity }) {
|
|
318
|
+
const s = await getSession();
|
|
319
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
320
|
+
const name = `${stamp()}-${String(activity.type).toLowerCase()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
321
|
+
const place = placeOf(actor, podHome);
|
|
322
|
+
const doc = { '@context': AS, ...activity, id: place.id(name), actor, published: new Date().toISOString() };
|
|
323
|
+
// Two requests to two different servers, and a network failure in either
|
|
324
|
+
// arrives as the browser's bare "Failed to fetch". Said on its own it names
|
|
325
|
+
// neither, so each is named here — the refusals already were.
|
|
326
|
+
let put;
|
|
327
|
+
try {
|
|
328
|
+
put = await s.fetch(place.at(name), { method: 'PUT', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(doc) });
|
|
329
|
+
} catch (e) {
|
|
330
|
+
throw new Error(`${e.message} — writing it to ${new URL(place.at(name)).host}`);
|
|
331
|
+
}
|
|
332
|
+
if (!put.ok) throw new Error(`your pod refused to publish the request (HTTP ${put.status})`);
|
|
333
|
+
let sent;
|
|
334
|
+
try {
|
|
335
|
+
sent = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(doc) });
|
|
336
|
+
} catch (e) {
|
|
337
|
+
throw new Error(`${e.message} — handing it to ${new URL(inbox).host}`);
|
|
338
|
+
}
|
|
339
|
+
if (!sent.ok) throw new Error(`the forum did not take the request (HTTP ${sent.status})`);
|
|
340
|
+
return doc.id;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The moderators' queue, read with the moderator's own pod login. It is not
|
|
344
|
+
// public and never passes through the Gateway: only a WebID the forum named
|
|
345
|
+
// can read it, which is the pod's own rule doing the work.
|
|
346
|
+
// A vote: a Like of the post, and its Undo to take it back. One per person;
|
|
347
|
+
// the forum counts them and publishes the total with the post.
|
|
348
|
+
// A vote, the way Lemmy federates one: a Like up, a Dislike down, and the Undo
|
|
349
|
+
// of whichever was cast to take it back. `way` is 'up', 'down' or 'none'.
|
|
350
|
+
export async function vote({ actor, post, category, inbox, way, was = null }) {
|
|
351
|
+
const cast = (type) => ({ '@context': AS, id: `${actor}#${type.toLowerCase()}-${Date.now()}`,
|
|
352
|
+
type, actor, object: post, to: [category] });
|
|
353
|
+
const body = way === 'none'
|
|
354
|
+
? { '@context': AS, id: `${actor}#unvote-${Date.now()}`, type: 'Undo', actor, to: [category],
|
|
355
|
+
object: { type: was === 'down' ? 'Dislike' : 'Like', actor, object: post } }
|
|
356
|
+
: cast(way === 'down' ? 'Dislike' : 'Like');
|
|
357
|
+
const r = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(body) });
|
|
358
|
+
if (!r.ok) throw new Error(`the forum did not take the vote (HTTP ${r.status})`);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function modQueue(podHome) {
|
|
363
|
+
const s = await getSession();
|
|
364
|
+
if (!s) throw new Error('sign in with your pod to see the queue');
|
|
365
|
+
const url = podHome.replace(/\/?$/u, '/') + 'mod/queue.json';
|
|
366
|
+
const r = await s.fetch(url, { headers: { accept: 'application/json' } });
|
|
367
|
+
if (r.status === 401 || r.status === 403) throw new Error('this account is not a moderator of this forum');
|
|
368
|
+
if (!r.ok) throw new Error(`the queue could not be read (HTTP ${r.status})`);
|
|
369
|
+
return r.json();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function join({ actor, category, inbox }) {
|
|
373
|
+
const follow = { '@context': AS, id: `${actor}#follow-${Date.now()}`, type: 'Follow', actor, object: category, to: [category] };
|
|
374
|
+
const r = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(follow) });
|
|
375
|
+
return r.ok;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Leaving: the category is told, and its own Accept/Reject bookkeeping does
|
|
379
|
+
// the rest. A member who leaves is no longer carried.
|
|
380
|
+
export async function leave({ actor, category, inbox }) {
|
|
381
|
+
const undo = {
|
|
382
|
+
'@context': AS, id: `${actor}#unfollow-${Date.now()}`, type: 'Undo', actor, to: [category],
|
|
383
|
+
object: { type: 'Follow', actor, object: category },
|
|
384
|
+
};
|
|
385
|
+
const r = await fetch(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(undo) });
|
|
386
|
+
return r.ok;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const htmlOf = (text) => toHtml(text);
|
|
390
|
+
const sourceOf = (text) => ({ content: String(text), mediaType: 'text/markdown' });
|
|
391
|
+
|
|
392
|
+
function escape_(s) {
|
|
393
|
+
return String(s).replace(/[&<>"]/gu, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
394
|
+
}
|
package/site/private.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// private.mjs — a post written for a private category, and nobody else.
|
|
2
|
+
//
|
|
3
|
+
// The forum cannot write access rules on somebody else's pod, so it publishes
|
|
4
|
+
// WHO the rule must name — the category's reader list, readable only by those
|
|
5
|
+
// same people — and this writes the rule on the author's own pod. A private
|
|
6
|
+
// category's posts go into one container per category, so one rule covers
|
|
7
|
+
// every post the author has made there and rewriting it on each post is what
|
|
8
|
+
// keeps an ejected member from reading the rest.
|
|
9
|
+
//
|
|
10
|
+
// If any step here fails the post must not be written. A post that lands
|
|
11
|
+
// without its rule is a public post in a category that told its author it was
|
|
12
|
+
// private, which is the whole thing this exists to prevent.
|
|
13
|
+
|
|
14
|
+
const ACL_CT = 'text/turtle';
|
|
15
|
+
const ACCEPT = 'application/activity+json, application/ld+json, application/json;q=0.9';
|
|
16
|
+
// A WebID or a target this writes into a rule. Anything else — a space, an
|
|
17
|
+
// angle bracket, a javascript: url — would end the IRI early and change what
|
|
18
|
+
// the document says.
|
|
19
|
+
const iri = (u) => typeof u === 'string' && /^https?:\/\/[^\s<>"'\\{}|^`]+$/u.test(u);
|
|
20
|
+
const safe = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 40);
|
|
21
|
+
|
|
22
|
+
// The one container a person's posts in one category live in. Named for the
|
|
23
|
+
// category so two forums, and two categories of one forum, never share a rule.
|
|
24
|
+
export function placeFor(categoryId) {
|
|
25
|
+
const u = new URL(categoryId);
|
|
26
|
+
const name = u.pathname.replace(/\/?ap\/actor\/?$/u, '').split('/').filter(Boolean).pop() || 'forum';
|
|
27
|
+
return `ap/private/${safe(u.host)}-${safe(name)}/`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Who the category says may read it. Read with the reader's own login: the
|
|
31
|
+
// list is restricted to the people on it, so a non-member is refused and a
|
|
32
|
+
// category with no list at all is not private. Null means open; an empty
|
|
33
|
+
// answer means private and not yours to write in.
|
|
34
|
+
export async function readersOf(categoryBase, session) {
|
|
35
|
+
const url = categoryBase + 'ap/members';
|
|
36
|
+
const r = await session.fetch(url, { headers: { accept: ACCEPT } }).catch(() => null);
|
|
37
|
+
if (!r) throw new Error('your pod could not reach the forum to ask who may read this category');
|
|
38
|
+
if (r.status === 404 || r.status === 410) return null;
|
|
39
|
+
if (r.status === 401 || r.status === 403) return [];
|
|
40
|
+
if (!r.ok) throw new Error(`the forum did not say who may read this category (HTTP ${r.status})`);
|
|
41
|
+
const doc = await r.json().catch(() => null);
|
|
42
|
+
const list = (doc?.orderedItems || doc?.items || []).filter(iri);
|
|
43
|
+
return list;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Where the rule for a container lives, as the pod itself says.
|
|
47
|
+
async function aclUrlOf(session, target) {
|
|
48
|
+
const r = await session.fetch(target, { method: 'HEAD' }).catch(() => null);
|
|
49
|
+
const link = r?.headers?.get('link') || '';
|
|
50
|
+
const said = /<([^>]+)>\s*;\s*rel\s*=\s*"?acl"?/iu.exec(link);
|
|
51
|
+
return said ? new URL(said[1], target).href : target + '.acl';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The rule itself: the owner in full, the named readers reading, nobody else.
|
|
55
|
+
// Written out here rather than with a library because this page loads nothing,
|
|
56
|
+
// and because the shape is fixed and short enough to read in one look.
|
|
57
|
+
export function aclDoc(target, owner, readers) {
|
|
58
|
+
if (!iri(target)) throw new Error('that is not a usable address to protect');
|
|
59
|
+
if (!iri(owner)) throw new Error('your pod did not give a usable WebID');
|
|
60
|
+
const named = readers.filter(iri);
|
|
61
|
+
const rule = (frag, who, modes) => `<#${frag}>\n`
|
|
62
|
+
+ ' a acl:Authorization;\n'
|
|
63
|
+
+ ` acl:agent <${who}>;\n`
|
|
64
|
+
+ ` acl:accessTo <${target}>;\n`
|
|
65
|
+
+ ` acl:default <${target}>;\n`
|
|
66
|
+
+ ` acl:mode ${modes.map(m => `acl:${m}`).join(', ')}.\n`;
|
|
67
|
+
return '@prefix acl: <http://www.w3.org/ns/auth/acl#>.\n\n'
|
|
68
|
+
+ rule('owner', owner, ['Read', 'Write', 'Control'])
|
|
69
|
+
+ named.map((who, i) => rule(`r${i}`, who, ['Read'])).join('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Make the container exist and say who may read it. Returns the container's
|
|
73
|
+
// address on the pod. Throws rather than leaving either half undone.
|
|
74
|
+
export async function prepare(session, container, owner, readers) {
|
|
75
|
+
// What went wrong, not only that something did: a refused request has a
|
|
76
|
+
// status, a request that never arrived has a reason, and "(HTTP ?)" said
|
|
77
|
+
// neither to whoever was trying to post.
|
|
78
|
+
let why = null;
|
|
79
|
+
const keep = await session.fetch(container + '.keep', {
|
|
80
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ keep: true }),
|
|
81
|
+
}).catch((e) => { why = e.message; return null; });
|
|
82
|
+
if (!keep?.ok) {
|
|
83
|
+
throw new Error(why
|
|
84
|
+
? `${why} — asking ${new URL(container).host} for ${container}`
|
|
85
|
+
: `your pod would not make a private place for these posts (HTTP ${keep.status}) — ${container}`);
|
|
86
|
+
}
|
|
87
|
+
const aclUrl = await aclUrlOf(session, container);
|
|
88
|
+
why = null;
|
|
89
|
+
const put = await session.fetch(aclUrl, {
|
|
90
|
+
method: 'PUT', headers: { 'content-type': ACL_CT }, body: aclDoc(container, owner, readers),
|
|
91
|
+
}).catch((e) => { why = e.message; return null; });
|
|
92
|
+
if (!put?.ok) {
|
|
93
|
+
throw new Error(why
|
|
94
|
+
? `${why} — writing the rule at ${aclUrl}, so nothing was written`
|
|
95
|
+
: `your pod would not keep this private (HTTP ${put.status}) at ${aclUrl} — nothing was written`);
|
|
96
|
+
}
|
|
97
|
+
return container;
|
|
98
|
+
}
|