fedipod 1.7.0 → 1.10.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 +14 -3
- package/gateway.md +16 -0
- package/groups.md +1 -0
- package/lib/client/c2s.mjs +112 -64
- package/lib/client/masto/index.mjs +9 -1
- package/lib/client/masto/render.mjs +5 -3
- package/lib/client/masto/statuses.mjs +7 -4
- package/lib/client/oidc-auth.mjs +10 -3
- package/lib/core/as2.mjs +1 -1
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/deliver.mjs +24 -1
- package/lib/core/intake/activities.mjs +54 -0
- package/lib/core/intake/index.mjs +34 -5
- package/lib/core/intake/notes.mjs +7 -0
- package/lib/core/publisher/collections.mjs +7 -4
- package/lib/core/publisher/index.mjs +49 -27
- package/lib/core/publisher/notes.mjs +154 -7
- package/lib/core/publisher/questions.mjs +11 -7
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +13 -0
- package/lib/core/wire.mjs +71 -20
- package/lib/device/admin/static.mjs +2 -2
- package/lib/device/admin/surface.mjs +11 -2
- package/lib/gateway/front-core.mjs +76 -6
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/gateway/httpsig.mjs +7 -3
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/transport.mjs +9 -3
- package/package.json +1 -1
- package/run-agent.mjs +11 -0
- package/scripts/refresh-contexts.mjs +5 -0
- package/scripts/stage-site.mjs +1 -0
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +12 -0
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +47 -7
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +1604 -722
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/site/_redirects +1 -0
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1605 -723
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
package/README.md
CHANGED
|
@@ -9,15 +9,14 @@ one timeline. Your posts, followers and settings stay on your pod.
|
|
|
9
9
|
The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. A wizard will walk you through getting a pod (you can also bring your own) and creating a Fediverse identity attached to the pod.
|
|
10
10
|
|
|
11
11
|
There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipod) which offer a variety of scenarios. If interested in the code, see also : [architecture overview](architecture.md) and [files overview](files-overview.md).
|
|
12
|
+
Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
|
|
12
13
|
|
|
13
14
|
## Requirements
|
|
14
15
|
|
|
15
16
|
- A current browser, on a desktop or a phone.
|
|
16
17
|
- A Solid pod with a host name of its own, such as
|
|
17
18
|
`https://alice.solidcommunity.net/`. Sign-up can create one for you at
|
|
18
|
-
solidcommunity.net or another provider, or use a pod you already have.
|
|
19
|
-
pod that lives on a suffix-based host, like `https://server.example/alice/`,
|
|
20
|
-
cannot be a Fediverse address.
|
|
19
|
+
solidcommunity.net or another provider, or use a pod you already have.
|
|
21
20
|
A pod on a suffix-based host, like `https://server.example/alice/`,
|
|
22
21
|
works too. Its address is then `@handle@fedipod.net`, because the shared
|
|
23
22
|
host cannot answer for the handle; your posts, key and data stay on your pod.
|
|
@@ -38,9 +37,16 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
|
|
|
38
37
|
4. Enter your pod password once. It creates the account and locks your
|
|
39
38
|
signing key. The password is not stored.
|
|
40
39
|
|
|
40
|
+
<!-- CLAUDE 2026-09-15 — the short profile address; delete these markers when done -->
|
|
41
|
+
Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
|
|
42
|
+
not.
|
|
43
|
+
<!-- /CLAUDE -->
|
|
44
|
+
|
|
41
45
|
Your account then opens in the client. To use it from another browser, go to
|
|
42
46
|
https://fedipod.net, enter your address, sign in at your pod, and unlock your
|
|
43
47
|
key with your password once on that browser.
|
|
48
|
+
If you have changed your pod password since you signed up, choose "make a new
|
|
49
|
+
signing key" on that screen and use the password you use now.
|
|
44
50
|
|
|
45
51
|
## What you can do
|
|
46
52
|
|
|
@@ -70,6 +76,11 @@ hands back stays in your browser unless you choose to keep it on your pod.
|
|
|
70
76
|
domain blocks import from the manage page. Your old account can be listed as
|
|
71
77
|
an alias, so a Move from it lands here.
|
|
72
78
|
|
|
79
|
+
**Posting from another app.** Any app that speaks ActivityPub
|
|
80
|
+
client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
81
|
+
address in your actor document, which your WebID profile also names, signed in
|
|
82
|
+
at your pod. The post goes out the next time you open fedipod.net.
|
|
83
|
+
|
|
73
84
|
**The manage page.** `manage account` in the bar opens it: your profile,
|
|
74
85
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
75
86
|
another server, retiring, and clearing a backlog. It is the same interface
|
package/gateway.md
CHANGED
|
@@ -10,6 +10,9 @@ Your name, your signing key and your data stay on your own pod. The gateway is
|
|
|
10
10
|
**keyless** — it never holds the key you sign with, so it cannot post as you,
|
|
11
11
|
read your private things, or be you anywhere. The worst a broken one can do is
|
|
12
12
|
push items into your inbox, and those still face your agent's own checks.
|
|
13
|
+
With the outbox door below, a gateway can also hand your agent a post marked as
|
|
14
|
+
yours, which your agent then signs and sends. It still holds no key, but what you
|
|
15
|
+
trust it with grows by that much.
|
|
13
16
|
|
|
14
17
|
A FediPod install works without any gateway at all. Deliveries go straight to
|
|
15
18
|
your pod inbox, which holds them whether your agent is running or not.
|
|
@@ -102,6 +105,19 @@ yourself. When you attach to a multi-user gateway, the direction is reversed:
|
|
|
102
105
|
the gateway mints the secret and answers the attach with it, and your agent
|
|
103
106
|
records it.
|
|
104
107
|
|
|
108
|
+
## The outbox door
|
|
109
|
+
|
|
110
|
+
The gateway also takes your own posts from any app that speaks ActivityPub
|
|
111
|
+
client-to-server, dokieli for one. Your actor document names the door as your
|
|
112
|
+
outbox, and your WebID profile names it as `as:outbox`. The app sends the post
|
|
113
|
+
there, signed in at your pod. The door checks that the token is yours, puts the
|
|
114
|
+
post in your inbox marked as yours, and answers with the address the post will
|
|
115
|
+
have. Your agent publishes it and sends it to your followers the next time it
|
|
116
|
+
runs: for a browser account, the next time you open the site.
|
|
117
|
+
|
|
118
|
+
A post with no audience of its own goes out as a public post. A post that is
|
|
119
|
+
not a note, an annotation say, is kept as the app sent it, under your name.
|
|
120
|
+
|
|
105
121
|
## What the gateway can see
|
|
106
122
|
|
|
107
123
|
It reads only public data to decide what concerns you: your published
|
package/groups.md
CHANGED
|
@@ -55,6 +55,7 @@ queue is its own list, separate from post review, and appears on the group's
|
|
|
55
55
|
admin page as **Moderation requests** when something is waiting: carry it out,
|
|
56
56
|
or turn it down.
|
|
57
57
|
A followed group's own announced deletion of a post it carried to you is honoured.
|
|
58
|
+
A group that withdraws a post it carried to you takes it off your timeline too.
|
|
58
59
|
|
|
59
60
|
## Inviting people
|
|
60
61
|
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// GETs on the actor and outbox are redirects: the pod's documents are the
|
|
9
9
|
// canonical ones, and a second renderer here would only drift from them.
|
|
10
10
|
//
|
|
11
|
+
// `dispatch` takes an activity and answers with { status, body, headers }; it
|
|
12
|
+
// touches no request or response. The HTTP handler below is one caller. The
|
|
13
|
+
// other is the inbox drain, handing over an activity the Gateway took at the
|
|
14
|
+
// outbox door on the owner's behalf and stamped as theirs.
|
|
15
|
+
//
|
|
11
16
|
// The inbox is the exception, and has to be. Deliveries land in a container on
|
|
12
17
|
// the pod which the drain empties as it handles each item, so reading that
|
|
13
18
|
// container tells the owner only what has not been dealt with yet. What was
|
|
@@ -18,6 +23,7 @@
|
|
|
18
23
|
import * as social from '../core/social.mjs';
|
|
19
24
|
import * as wire from '../core/wire.mjs';
|
|
20
25
|
import { readLenient } from '../core/as2.mjs';
|
|
26
|
+
import { safeSlug } from '../core/publisher/notes.mjs';
|
|
21
27
|
|
|
22
28
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
23
29
|
|
|
@@ -154,7 +160,10 @@ export class C2S {
|
|
|
154
160
|
async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
|
|
155
161
|
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
|
|
156
162
|
if (req.method === 'OPTIONS') {
|
|
157
|
-
|
|
163
|
+
// Accept-Post is what a client such as dokieli reads to choose a format;
|
|
164
|
+
// naming JSON only is what makes it send JSON-LD rather than HTML.
|
|
165
|
+
res.writeHead(204, pathname === '/ap/inbox' ? { allow: 'GET, OPTIONS' }
|
|
166
|
+
: { allow: 'GET, POST, OPTIONS', 'accept-post': 'application/ld+json, application/activity+json' });
|
|
158
167
|
res.end(); return true;
|
|
159
168
|
}
|
|
160
169
|
if (!this.agent.configured() || !this.urls) {
|
|
@@ -166,7 +175,7 @@ export class C2S {
|
|
|
166
175
|
+ 'actor document names; this address is the owner reading their own' });
|
|
167
176
|
}
|
|
168
177
|
const reader = await this.auth(req, pathname);
|
|
169
|
-
if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
|
|
178
|
+
if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
|
|
170
179
|
return this.sendInbox(res, url);
|
|
171
180
|
}
|
|
172
181
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
@@ -181,7 +190,7 @@ export class C2S {
|
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
const who = await this.auth(req, pathname);
|
|
184
|
-
if (!who.ok) return this.send(res, who.status, { error: who.error });
|
|
193
|
+
if (!who.ok) return this.send(res, who.status, { error: who.error }, who.headers || {});
|
|
185
194
|
|
|
186
195
|
// A viewer (another agent holds the lease) may not act — but the user
|
|
187
196
|
// acting HERE outranks an idle active agent elsewhere, exactly as on the
|
|
@@ -196,9 +205,16 @@ export class C2S {
|
|
|
196
205
|
// takes decisions from it and the publisher builds the document that is
|
|
197
206
|
// actually posted, so nothing a client sent is republished verbatim and a
|
|
198
207
|
// term it aliased still means what it says.
|
|
199
|
-
|
|
208
|
+
// `raw` is the document as the client wrote it. The graph is what
|
|
209
|
+
// decisions are taken from; the bytes are what an object that is stored
|
|
210
|
+
// as sent (publishObject) is stored from — a read against our contexts
|
|
211
|
+
// rewrites a stranger's terms to full IRIs, which is right for reading and
|
|
212
|
+
// wrong for keeping.
|
|
213
|
+
let activity; let raw = null;
|
|
200
214
|
try {
|
|
201
|
-
const
|
|
215
|
+
const body = await readBody(req);
|
|
216
|
+
try { raw = JSON.parse(body); } catch { raw = null; }
|
|
217
|
+
const read = await readLenient(body);
|
|
202
218
|
activity = read.view ?? read.doc;
|
|
203
219
|
} catch (e) {
|
|
204
220
|
return this.send(res, 400, { error: `unreadable body: ${e.message}` });
|
|
@@ -206,34 +222,57 @@ export class C2S {
|
|
|
206
222
|
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
207
223
|
return this.send(res, 400, { error: 'a typed ActivityStreams object is required' });
|
|
208
224
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
return await this.dispatch(res, activity);
|
|
217
|
-
} catch (e) {
|
|
218
|
-
this.log(`c2s ${activity.type}: ${e.message}`);
|
|
219
|
-
return this.send(res, 422, { error: e.message || String(e) });
|
|
220
|
-
}
|
|
225
|
+
// The name the client asks for its new document (LDP's Slug), taken when
|
|
226
|
+
// it is plain and free — it is what lets the client know the address of
|
|
227
|
+
// what it made before anything answers.
|
|
228
|
+
const slug = safeSlug(req.headers.slug) || null;
|
|
229
|
+
const r = await this.dispatch(activity, { slug, raw });
|
|
230
|
+
return this.send(res, r.status, r.body, r.headers);
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
// Addressing → the facade's four visibilities, inverting the table the
|
|
224
|
-
// composer writes (wire.
|
|
225
|
-
//
|
|
234
|
+
// composer writes (wire.addressing). Nothing stated is a public post — what
|
|
235
|
+
// every client means by a post with no audience chosen, and what a client
|
|
236
|
+
// that never addresses (dokieli's annotations) needs.
|
|
226
237
|
visibilityOf(activity, object) {
|
|
227
238
|
const to = arr(activity.to ?? object?.to).map(idOf);
|
|
228
239
|
const cc = arr(activity.cc ?? object?.cc).map(idOf);
|
|
229
|
-
if (!to.length && !cc.length) return
|
|
240
|
+
if (!to.length && !cc.length) return 'public';
|
|
230
241
|
if (to.includes(wire.PUBLIC)) return 'public';
|
|
231
242
|
if (cc.includes(wire.PUBLIC)) return 'unlisted';
|
|
232
243
|
if (to.includes(this.urls.followers)) return 'private';
|
|
233
244
|
return 'direct';
|
|
234
245
|
}
|
|
235
246
|
|
|
236
|
-
async dispatch(
|
|
247
|
+
async dispatch(activity, { slug = null, raw = null } = {}) {
|
|
248
|
+
const reply = (status, body, headers = {}) => ({ status, body, headers });
|
|
249
|
+
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
250
|
+
return reply(400, { error: 'a typed ActivityStreams object is required' });
|
|
251
|
+
}
|
|
252
|
+
// A bare object arrives without an activity around it; the server supplies
|
|
253
|
+
// the Create (§6.2.1), carrying the object's own addressing up onto it.
|
|
254
|
+
if (!ACTIVITY_TYPES.has(activity.type)) {
|
|
255
|
+
activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return await this._dispatch(activity, { slug, raw, reply });
|
|
259
|
+
} catch (e) {
|
|
260
|
+
this.log(`c2s ${activity?.type}: ${e.message}`);
|
|
261
|
+
return reply(422, { error: e.message || String(e) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The actors a client addressed by id, beyond the audience words: the ones
|
|
266
|
+
// in to/cc are listed and delivered to; the ones in bto/bcc are delivered
|
|
267
|
+
// to and never listed (§6).
|
|
268
|
+
addressedActors(activity, object) {
|
|
269
|
+
const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
|
|
270
|
+
.filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
|
|
271
|
+
&& a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
|
|
272
|
+
return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async _dispatch(activity, { slug, raw, reply }) {
|
|
237
276
|
const agent = this.agent;
|
|
238
277
|
const object = typeof activity.object === 'object' && activity.object !== null
|
|
239
278
|
? activity.object : null;
|
|
@@ -242,18 +281,23 @@ export class C2S {
|
|
|
242
281
|
switch (activity.type) {
|
|
243
282
|
case 'Create': {
|
|
244
283
|
const makes = object?.type || 'Note';
|
|
245
|
-
if (!object
|
|
246
|
-
return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
|
|
247
|
-
}
|
|
284
|
+
if (!object) return reply(422, { error: 'a Create carries the object it creates' });
|
|
248
285
|
const visibility = this.visibilityOf(activity, object);
|
|
249
|
-
|
|
250
|
-
|
|
286
|
+
const { also, deliverTo } = this.addressedActors(activity, object);
|
|
287
|
+
// Not a Note and not a poll: stored as sent, under this actor, and
|
|
288
|
+
// the Create around it delivered — a Web Annotation, for one.
|
|
289
|
+
if (makes !== 'Note' && makes !== 'Question') {
|
|
290
|
+
const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
291
|
+
? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
|
|
292
|
+
: null;
|
|
293
|
+
const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo });
|
|
294
|
+
return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
|
|
251
295
|
}
|
|
252
296
|
// `source.content` is the client's plain text when it sends one; bare
|
|
253
297
|
// `content` is TREATED as plain text and escaped — markup survives as
|
|
254
298
|
// visible characters rather than as markup. Documented v1 limit.
|
|
255
299
|
const text = String(object.source?.content ?? object.content ?? '');
|
|
256
|
-
if (!text.trim()) return
|
|
300
|
+
if (!text.trim()) return reply(422, { error: 'the note has no content' });
|
|
257
301
|
|
|
258
302
|
// A Question is a poll: the choices are in oneOf (pick one) or anyOf
|
|
259
303
|
// (pick several), each naming itself, and endTime is when it shuts.
|
|
@@ -269,12 +313,13 @@ export class C2S {
|
|
|
269
313
|
inReplyTo: idOf(object.inReplyTo) || undefined,
|
|
270
314
|
visibility,
|
|
271
315
|
spoilerText: object.summary || null,
|
|
316
|
+
sensitive: object.sensitive === true,
|
|
272
317
|
});
|
|
273
|
-
return
|
|
318
|
+
return reply(201,
|
|
274
319
|
{ id: wire.createActivityId(question.id), object: question.id },
|
|
275
320
|
{ location: wire.createActivityId(question.id) });
|
|
276
321
|
} catch (e) {
|
|
277
|
-
return
|
|
322
|
+
return reply(422, { error: e.message });
|
|
278
323
|
}
|
|
279
324
|
}
|
|
280
325
|
|
|
@@ -287,22 +332,24 @@ export class C2S {
|
|
|
287
332
|
attachments,
|
|
288
333
|
visibility,
|
|
289
334
|
spoilerText: object.summary || null,
|
|
335
|
+
sensitive: object.sensitive === true,
|
|
336
|
+
slug, also, deliverTo,
|
|
290
337
|
});
|
|
291
|
-
return
|
|
338
|
+
return reply(201, { id: wire.createActivityId(note.id), object: note.id },
|
|
292
339
|
{ location: wire.createActivityId(note.id) });
|
|
293
340
|
}
|
|
294
341
|
|
|
295
342
|
case 'Update': {
|
|
296
343
|
if (objectId === this.urls.actor) {
|
|
297
|
-
return
|
|
344
|
+
return reply(422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
|
|
298
345
|
}
|
|
299
346
|
const s = this.byIri(objectId);
|
|
300
|
-
if (!s) return
|
|
347
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
301
348
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
302
|
-
return
|
|
349
|
+
return reply(403, { error: 'not your note' });
|
|
303
350
|
}
|
|
304
351
|
const text = String(object?.source?.content ?? object?.content ?? '');
|
|
305
|
-
if (!text.trim()) return
|
|
352
|
+
if (!text.trim()) return reply(422, { error: 'the edit has no content' });
|
|
306
353
|
const attachments = object?.attachment !== undefined
|
|
307
354
|
? arr(object.attachment).map((a) => ({
|
|
308
355
|
url: a?.url, mediaType: a?.mediaType,
|
|
@@ -310,54 +357,55 @@ export class C2S {
|
|
|
310
357
|
})).filter((a) => a.url)
|
|
311
358
|
: null;
|
|
312
359
|
await agent.publisher.updateNote(s, {
|
|
313
|
-
content: text, spoilerText: object?.summary || null,
|
|
360
|
+
content: text, spoilerText: object?.summary || null,
|
|
361
|
+
sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
|
|
314
362
|
});
|
|
315
|
-
return
|
|
363
|
+
return reply(200, { ok: true, object: s.noteId });
|
|
316
364
|
}
|
|
317
365
|
|
|
318
366
|
case 'Delete': {
|
|
319
367
|
if (objectId === this.urls.actor) {
|
|
320
|
-
return
|
|
368
|
+
return reply(422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
|
|
321
369
|
}
|
|
322
370
|
const s = this.byIri(objectId);
|
|
323
|
-
if (!s) return
|
|
371
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
324
372
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
325
|
-
return
|
|
373
|
+
return reply(403, { error: 'not your note' });
|
|
326
374
|
}
|
|
327
375
|
const r = await social.deleteNote(agent, s);
|
|
328
|
-
if (!r.ok) return
|
|
329
|
-
return
|
|
376
|
+
if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
|
|
377
|
+
return reply(200, { ok: true });
|
|
330
378
|
}
|
|
331
379
|
|
|
332
380
|
case 'Follow': {
|
|
333
|
-
if (!objectId) return
|
|
381
|
+
if (!objectId) return reply(400, { error: 'whom? object must name an actor' });
|
|
334
382
|
// An acct: form or bare handle resolves through WebFinger; an https
|
|
335
383
|
// IRI is fetched directly.
|
|
336
384
|
if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
|
|
337
385
|
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
|
|
338
386
|
const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
|
|
339
|
-
return
|
|
387
|
+
return reply(201, { id: rec?.followActivity?.id, object: r.actor },
|
|
340
388
|
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
341
389
|
}
|
|
342
390
|
const doc = await social.followActor(agent, objectId);
|
|
343
391
|
const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
|
|
344
|
-
return
|
|
392
|
+
return reply(201, { id: rec?.followActivity?.id, object: doc.id },
|
|
345
393
|
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
346
394
|
}
|
|
347
395
|
|
|
348
396
|
case 'Like': {
|
|
349
397
|
const s = this.byIri(objectId);
|
|
350
|
-
if (!s) return
|
|
398
|
+
if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
|
|
351
399
|
const updated = await social.favourite(agent, s);
|
|
352
|
-
return
|
|
400
|
+
return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
|
|
353
401
|
updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
|
|
354
402
|
}
|
|
355
403
|
|
|
356
404
|
case 'Announce': {
|
|
357
405
|
const s = this.byIri(objectId);
|
|
358
|
-
if (!s) return
|
|
406
|
+
if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
|
|
359
407
|
const updated = await social.reblog(agent, s);
|
|
360
|
-
return
|
|
408
|
+
return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
|
|
361
409
|
updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
|
|
362
410
|
}
|
|
363
411
|
|
|
@@ -368,37 +416,37 @@ export class C2S {
|
|
|
368
416
|
const innerId = idOf(activity.object);
|
|
369
417
|
if (inner?.type === 'Block') {
|
|
370
418
|
const target = idOf(inner.object);
|
|
371
|
-
if (!target) return
|
|
419
|
+
if (!target) return reply(400, { error: 'unblock whom?' });
|
|
372
420
|
await social.unblockActor(agent, target);
|
|
373
|
-
return
|
|
421
|
+
return reply(200, { ok: true, object: target });
|
|
374
422
|
}
|
|
375
423
|
const statuses = this.store.getStatuses();
|
|
376
424
|
let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
|
|
377
425
|
if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
|
|
378
426
|
if (s?.favourited) {
|
|
379
427
|
const updated = await social.unfavourite(agent, s);
|
|
380
|
-
return
|
|
428
|
+
return reply(200, { ok: true, object: updated.noteId });
|
|
381
429
|
}
|
|
382
430
|
s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
|
|
383
431
|
if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
|
|
384
432
|
if (s?.reblogged) {
|
|
385
433
|
const updated = await social.unreblog(agent, s);
|
|
386
|
-
return
|
|
434
|
+
return reply(200, { ok: true, object: updated.noteId });
|
|
387
435
|
}
|
|
388
436
|
const following = this.store.getContacts().following;
|
|
389
437
|
const rec = following.find((f) => f.followActivity?.id === innerId)
|
|
390
438
|
|| (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
|
|
391
439
|
if (rec) {
|
|
392
440
|
await social.unfollowActor(agent, rec.actor);
|
|
393
|
-
return
|
|
441
|
+
return reply(200, { ok: true, object: rec.actor });
|
|
394
442
|
}
|
|
395
|
-
return
|
|
443
|
+
return reply(422, { error: 'nothing here matches what that Undo names' });
|
|
396
444
|
}
|
|
397
445
|
|
|
398
446
|
case 'Block': {
|
|
399
|
-
if (!objectId) return
|
|
447
|
+
if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
|
|
400
448
|
await social.blockActor(agent, objectId);
|
|
401
|
-
return
|
|
449
|
+
return reply(200, { ok: true, object: objectId });
|
|
402
450
|
}
|
|
403
451
|
|
|
404
452
|
case 'Add':
|
|
@@ -406,12 +454,12 @@ export class C2S {
|
|
|
406
454
|
// The one collection a client may edit is the pins (§7.6/§7.9 in the
|
|
407
455
|
// other direction): target must be the featured collection.
|
|
408
456
|
if (idOf(activity.target) !== this.urls.featured) {
|
|
409
|
-
return
|
|
457
|
+
return reply(422, { error: 'the featured collection is the one Add/Remove edits here' });
|
|
410
458
|
}
|
|
411
459
|
const s = this.byIri(objectId);
|
|
412
|
-
if (!s) return
|
|
460
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
413
461
|
const updated = await social.pinStatus(agent, s, activity.type === 'Add');
|
|
414
|
-
return
|
|
462
|
+
return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
|
|
415
463
|
}
|
|
416
464
|
|
|
417
465
|
case 'Accept':
|
|
@@ -419,19 +467,19 @@ export class C2S {
|
|
|
419
467
|
// Answering a held follow request: the object is the Follow (or the
|
|
420
468
|
// requester). Which request is meant comes from the Follow's actor.
|
|
421
469
|
const requester = object?.actor ? idOf(object.actor) : objectId;
|
|
422
|
-
if (!requester) return
|
|
470
|
+
if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
|
|
423
471
|
const r = activity.type === 'Accept'
|
|
424
472
|
? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
|
|
425
473
|
: await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
|
|
426
|
-
if (r.error) return
|
|
427
|
-
return
|
|
474
|
+
if (r.error) return reply(404, { error: r.error });
|
|
475
|
+
return reply(200, { ok: true, object: requester });
|
|
428
476
|
}
|
|
429
477
|
|
|
430
478
|
case 'Move':
|
|
431
|
-
return
|
|
479
|
+
return reply(422, { error: 'moving the account is done on the admin surface, where it asks twice' });
|
|
432
480
|
|
|
433
481
|
default:
|
|
434
|
-
return
|
|
482
|
+
return reply(422, { error: `no handler for ${activity.type}` });
|
|
435
483
|
}
|
|
436
484
|
}
|
|
437
485
|
}
|
|
@@ -117,7 +117,15 @@ export class MastoApi {
|
|
|
117
117
|
res.end(body);
|
|
118
118
|
return true;
|
|
119
119
|
};
|
|
120
|
-
|
|
120
|
+
// The address the client reached, so every self-URL handed back — the
|
|
121
|
+
// pagination links above all — names it rather than the pod's host.
|
|
122
|
+
const scheme = String(this.scheme
|
|
123
|
+
|| (req.headers?.['x-forwarded-proto'] === 'https' || req.socket?.encrypted ? 'https' : 'http')).replace(/:.*$/, '');
|
|
124
|
+
let self = url;
|
|
125
|
+
try {
|
|
126
|
+
self = new URL(`${scheme}://${req.headers?.host || 'localhost'}${this.mount || ''}${pathname}${url.search || ''}`);
|
|
127
|
+
} catch { /* a Host nothing can parse: the links keep the placeholder origin */ }
|
|
128
|
+
const ctx = { req, res, pathname, url: self, send };
|
|
121
129
|
|
|
122
130
|
if (await oauth.handle(this, ctx)) return true;
|
|
123
131
|
if (!pathname.startsWith('/api/')) return false;
|
|
@@ -102,7 +102,9 @@ export function page(api, items, url, { limit = 20, max = 40, idOf = (s) => api.
|
|
|
102
102
|
}
|
|
103
103
|
const pageItems = items.slice(0, n);
|
|
104
104
|
if (!pageItems.length) return { items: pageItems, headers: {} };
|
|
105
|
-
|
|
105
|
+
// `url` is the request as the client addressed it (index.mjs handle), so
|
|
106
|
+
// the links name that origin and mount, not the pod's host.
|
|
107
|
+
const base = url.origin + url.pathname;
|
|
106
108
|
const q = (extra) => {
|
|
107
109
|
const u = new URL(base);
|
|
108
110
|
for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
|
|
@@ -222,7 +224,7 @@ export function status(api, s, { all } = {}) {
|
|
|
222
224
|
created_at: s.published || new Date().toISOString(),
|
|
223
225
|
in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
|
|
224
226
|
in_reply_to_account_id: null,
|
|
225
|
-
sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
|
|
227
|
+
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
|
|
226
228
|
visibility: s.visibility || 'public', language: null,
|
|
227
229
|
edited_at: s.editedAt || null,
|
|
228
230
|
uri: s.noteId, url: s.link || s.noteId,
|
|
@@ -251,7 +253,7 @@ export function status(api, s, { all } = {}) {
|
|
|
251
253
|
acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
|
|
252
254
|
};
|
|
253
255
|
}),
|
|
254
|
-
tags: [],
|
|
256
|
+
tags: (s.tags || []).map(t => ({ name: t.name, url: t.url })),
|
|
255
257
|
emojis: (s.emojis || []).map(e => ({
|
|
256
258
|
shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
|
|
257
259
|
})),
|
|
@@ -37,6 +37,7 @@ export async function handle(api, ctx) {
|
|
|
37
37
|
if (ready !== true) return send(422, { error: ready });
|
|
38
38
|
}
|
|
39
39
|
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
40
|
+
const sensitive = body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1';
|
|
40
41
|
const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
|
|
41
42
|
const media = api.store.getMedia();
|
|
42
43
|
const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
|
|
@@ -67,7 +68,7 @@ export async function handle(api, ctx) {
|
|
|
67
68
|
const q = await api.agent.publisher.publishQuestion(body.status, {
|
|
68
69
|
options: asking.options, multiple: asking.multiple,
|
|
69
70
|
expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
|
|
70
|
-
inReplyTo, visibility, spoilerText,
|
|
71
|
+
inReplyTo, visibility, spoilerText, sensitive,
|
|
71
72
|
});
|
|
72
73
|
return send(200, api.status(api.store.getStatuses().find(x => x.noteId === q.id)));
|
|
73
74
|
} catch (e) {
|
|
@@ -98,7 +99,7 @@ export async function handle(api, ctx) {
|
|
|
98
99
|
let note;
|
|
99
100
|
try {
|
|
100
101
|
note = await api.agent.publisher.publishNote(body.status,
|
|
101
|
-
{ inReplyTo, attachments, visibility, spoilerText });
|
|
102
|
+
{ inReplyTo, attachments, visibility, spoilerText, sensitive });
|
|
102
103
|
} catch (e) {
|
|
103
104
|
// A direct message nobody could be found for: the sender's mistake to
|
|
104
105
|
// fix, said in the client, not a fault of the server.
|
|
@@ -126,7 +127,7 @@ export async function handle(api, ctx) {
|
|
|
126
127
|
const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
127
128
|
if (!s) return send(404, { error: 'Record not found' });
|
|
128
129
|
return send(200, [{
|
|
129
|
-
content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
|
|
130
|
+
content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!(s.spoiler || s.sensitive),
|
|
130
131
|
created_at: s.editedAt || s.published, account: api.account(s.actor),
|
|
131
132
|
media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
|
|
132
133
|
emojis: [], poll: null,
|
|
@@ -152,8 +153,10 @@ export async function handle(api, ctx) {
|
|
|
152
153
|
? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
|
|
153
154
|
: null; // null: keep what the post has
|
|
154
155
|
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
156
|
+
const sensitive = body.sensitive === undefined ? null
|
|
157
|
+
: (body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1');
|
|
155
158
|
const patched = await api.agent.publisher.updateNote(s,
|
|
156
|
-
{ content: body.status, spoilerText, attachments });
|
|
159
|
+
{ content: body.status, spoilerText, sensitive, attachments });
|
|
157
160
|
return send(200, api.status(patched || s));
|
|
158
161
|
}
|
|
159
162
|
if (mStatus && req.method === 'DELETE') {
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// oidc-auth.mjs — who may drive the C2S surface. Two proofs are accepted:
|
|
2
2
|
// the Mastodon facade's bearer (minted on this machine, so its holder is the
|
|
3
|
-
// operator by construction), or a Solid-OIDC token
|
|
4
|
-
//
|
|
3
|
+
// operator by construction), or a Solid-OIDC token with its DPoP proof,
|
|
4
|
+
// which Solid-OIDC requires, verified by the same library the wider
|
|
5
5
|
// Solid world uses. The token alone is not enough: the WebID it names must
|
|
6
6
|
// be THIS identity's owner, an authorization step the token does not carry.
|
|
7
7
|
//
|
|
@@ -25,6 +25,13 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
25
25
|
if (!req.headers.authorization) {
|
|
26
26
|
return { ok: false, status: 401, error: 'authentication required: a Solid-OIDC token (DPoP) or this agent\'s own bearer' };
|
|
27
27
|
}
|
|
28
|
+
// Solid-OIDC binds the token to a key and has the client prove it holds
|
|
29
|
+
// that key on every request. A token shown without its proof is a token
|
|
30
|
+
// anyone who saw it could show.
|
|
31
|
+
if (!req.headers.dpop) {
|
|
32
|
+
return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
|
|
33
|
+
headers: { 'www-authenticate': 'DPoP' } };
|
|
34
|
+
}
|
|
28
35
|
let webid;
|
|
29
36
|
try {
|
|
30
37
|
const v = await loadVerifier();
|
|
@@ -38,7 +45,7 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
38
45
|
}://${req.headers.host}${mount}${pathname}`;
|
|
39
46
|
({ webid } = await v(
|
|
40
47
|
req.headers.authorization,
|
|
41
|
-
|
|
48
|
+
{ header: req.headers.dpop, method: req.method, url: htu },
|
|
42
49
|
));
|
|
43
50
|
} catch (e) {
|
|
44
51
|
log(`c2s auth: token rejected — ${e.message}`);
|
package/lib/core/as2.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// common shape: one that skipped it would not be read as JSON-LD, and would
|
|
20
20
|
// produce no graph, so nothing could be validated against it.
|
|
21
21
|
//
|
|
22
|
-
// CONTEXTS ARE NEVER FETCHED. The loader serves the
|
|
22
|
+
// CONTEXTS ARE NEVER FETCHED. The loader serves the fifteen the fediverse
|
|
23
23
|
// actually uses and refuses every other URL. That refusal is the point: this
|
|
24
24
|
// code runs on documents a stranger wrote, in the same path the SSRF guards
|
|
25
25
|
// protect, and expanding one would otherwise mean dereferencing a URL of their
|