fedipod 1.36.6 → 1.39.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 -1
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +1 -1
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +33 -2
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/social.mjs +62 -32
- package/lib/core/store.mjs +11 -1
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/front-core.mjs +54 -58
- package/lib/gateway/gateway-core.mjs +87 -8
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +171 -22
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +2 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +11 -2
- package/web/app/boot.mjs +72 -32
- package/web/app/dist/boot.js +430 -86
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +3294 -2648
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/README.md
CHANGED
|
@@ -23,6 +23,11 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
|
|
|
23
23
|
2. Choose your pod provider. You'll be redirected to your provider to login if you have a pod or sign up if you need to create one.
|
|
24
24
|
3. Back on fedipod.net, choose your handle and where your address lives:
|
|
25
25
|
on your pod, `@handle@yourpod`, or at this site, `@handle@fedipod.net`.
|
|
26
|
+
Choose where on your pod your data goes: a container named `fedipod`,
|
|
27
|
+
inside whichever container you name (your pod's root if you leave it
|
|
28
|
+
empty). If your pod has no public type index yet, you're asked whether
|
|
29
|
+
to create one; FediPod records there where your account lives, and
|
|
30
|
+
nothing is set up without it.
|
|
26
31
|
4. You see your Fediverse timeline.
|
|
27
32
|
|
|
28
33
|
From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
|
|
@@ -71,6 +76,10 @@ an alias, so a Move from it lands here.
|
|
|
71
76
|
client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
72
77
|
address in your actor document, which your WebID profile also names, signed in
|
|
73
78
|
at your pod. The post goes out the next time you open fedipod.net.
|
|
79
|
+
If your account turns a post down when it runs, you get a direct message from
|
|
80
|
+
yourself saying what was refused and why. Signed in, your outbox shows you
|
|
81
|
+
everything you've done, likes and follows included; everyone else sees only
|
|
82
|
+
your public posts.
|
|
74
83
|
|
|
75
84
|
**The manage page.** `manage`, in the bar's account group, opens it: your profile,
|
|
76
85
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
@@ -98,7 +107,11 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
|
98
107
|
|
|
99
108
|
Everything you publish and everything you read is stored on your pod. Your
|
|
100
109
|
signing key is stored there too, in a container only you can read through
|
|
101
|
-
your pod's login.
|
|
110
|
+
your pod's login.
|
|
111
|
+
It all lives in the `fedipod` container you chose at sign-up. Your pod's
|
|
112
|
+
public type index records it, as an ActivityStreams actor, which is how
|
|
113
|
+
FediPod and other Solid apps find your account again.
|
|
114
|
+
fedipod.net holds no key: it verifies incoming mail, drops
|
|
102
115
|
the junk, forwards the rest to your pod, and hands your browser the app.
|
|
103
116
|
With an address on your pod, `@handle@yourpod`, you can detach from it at
|
|
104
117
|
any time and attach to a gateway of your own; your address and your data do
|
package/bin/fedipod.mjs
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// --pod-name, --group, --summary, --icon, --root, --keys) keeps setup
|
|
26
26
|
// entirely on the command line, as does a non-TTY stdin. --cli forces it.
|
|
27
27
|
//
|
|
28
|
+
// --in <container> the container on the pod that holds `fedipod/`
|
|
29
|
+
// (asked when not given; empty is the pod's root)
|
|
30
|
+
// --create-type-index make a public type index when the pod has none;
|
|
31
|
+
// where the account lives is recorded there, and
|
|
32
|
+
// without one and without this, setup stops
|
|
33
|
+
//
|
|
28
34
|
// The password is prompted (or AP_PASSWORD) — used once to create the
|
|
29
35
|
// account and/or mint a revocable CSS client-credential, never stored.
|
|
30
36
|
// Keys live in AP_HOME by default (the pod host cannot read them);
|
package/cli.md
CHANGED
|
@@ -32,10 +32,16 @@ prompt. `--profile NAME` names the new identity when you have more than one.
|
|
|
32
32
|
`--address pod` (the default) puts your address on your pod, `@you@yourpod`;
|
|
33
33
|
`--address front` puts it at a gateway, `@you@the-gateway`, with your posts,
|
|
34
34
|
key and data still on your pod. `--gateway <url>` names the gateway
|
|
35
|
-
(`https://fedipod.net` by default). A pod
|
|
35
|
+
(`https://fedipod.net` by default). A suffixed pod cannot
|
|
36
36
|
answer WebFinger for a handle, so it always takes a gateway address, whichever
|
|
37
37
|
`--address` you gave.
|
|
38
38
|
|
|
39
|
+
Your data goes in a container named `fedipod`, inside the container you name
|
|
40
|
+
with `--in <path>` (asked when you leave it out; empty is your pod's root). Where
|
|
41
|
+
it lives is recorded in your pod's public type index. If your pod has none,
|
|
42
|
+
setup asks before making one, and `--create-type-index` says yes in advance;
|
|
43
|
+
without a yes, setup stops before anything is written.
|
|
44
|
+
|
|
39
45
|
## Which identity a command acts on
|
|
40
46
|
|
|
41
47
|
`--profile NAME` works on every command, not just `up`; `AP_PROFILE` is the
|
package/device-agent.md
CHANGED
|
@@ -12,9 +12,9 @@ connect to it; and it can host a [group](groups.md).
|
|
|
12
12
|
- Node 20 or newer.
|
|
13
13
|
- A Solid pod, such as `https://alice.solidcommunity.net/` or
|
|
14
14
|
`https://server.example/alice/`. The address is `@handle@yourpod`, or
|
|
15
|
-
`@handle@fedipod.net` when the pod is
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
`@handle@fedipod.net` when the pod is suffixed, since a suffixed pod
|
|
16
|
+
cannot answer for the handle; the posts, key and data stay on the pod
|
|
17
|
+
either way.
|
|
18
18
|
- Followers-only and direct posts need a pod that enforces WAC access control;
|
|
19
19
|
on one that does not, the composer refuses those two and says why.
|
|
20
20
|
- While the agent is off, your mail waits on your pod's host. Run it as a
|
package/gateway.md
CHANGED
|
@@ -32,7 +32,7 @@ password is typed anywhere:
|
|
|
32
32
|
restarts the agent itself to publish under it.
|
|
33
33
|
|
|
34
34
|
In the BrowserAgent the same choice is made once, at sign-up, and cannot be
|
|
35
|
-
changed afterwards. A pod
|
|
35
|
+
changed afterwards. A suffixed pod, like
|
|
36
36
|
`https://server.example/alice/`, always takes the gateway-based name: nothing
|
|
37
37
|
at that host answers for the handle, so the gateway does. Its posts, key and
|
|
38
38
|
data stay on the pod.
|
package/groups.md
CHANGED
|
@@ -60,7 +60,8 @@ A group that withdraws a post it carried to you takes it off your timeline too.
|
|
|
60
60
|
## Inviting people
|
|
61
61
|
|
|
62
62
|
A group has a page anyone can open, at `ap/profile.html` under its pod's
|
|
63
|
-
app container — `<pod>/fedipod/ap/profile.html
|
|
63
|
+
app container — `<pod>/fedipod/ap/profile.html`
|
|
64
|
+
by default, or under the `fedipod` container wherever it was put at setup. It
|
|
64
65
|
carries the group's address and a Follow box that sends a visitor to their
|
|
65
66
|
own server's follow screen, so it is the link to put where people will find
|
|
66
67
|
it. Posts the group carries appear in members' timelines as the group
|
package/gui.md
CHANGED
|
@@ -46,7 +46,9 @@ its own port.
|
|
|
46
46
|
## Sharing an account
|
|
47
47
|
|
|
48
48
|
Each identity has a page anyone can open, at `ap/profile.html` under its pod
|
|
49
|
-
— for example `https://your-pod.example/fedipod/ap/profile.html
|
|
49
|
+
— for example `https://your-pod.example/fedipod/ap/profile.html`
|
|
50
|
+
(the `fedipod` container is wherever you put it at setup; the manage page's
|
|
51
|
+
"stored at" row says where). It
|
|
50
52
|
shows the name, bio and address, and offers a Follow box: a visitor types
|
|
51
53
|
their own server and lands on that server's follow screen. Hand out that
|
|
52
54
|
link, or the `@name@host` address itself, which works in the search box of
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import * as social from '../core/social.mjs';
|
|
|
28
28
|
import * as wire from '../core/wire.mjs';
|
|
29
29
|
import { readLenient } from '../core/as2.mjs';
|
|
30
30
|
import { safeSlug } from '../core/publisher/notes.mjs';
|
|
31
|
+
import * as podNotes from '../pod/notes.mjs';
|
|
31
32
|
|
|
32
33
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
33
34
|
|
|
@@ -192,9 +193,11 @@ export class C2S {
|
|
|
192
193
|
* published copy and it stays the canonical one; this is served only where
|
|
193
194
|
* that copy cannot be read by the client asking.
|
|
194
195
|
*/
|
|
195
|
-
sendLocalOutbox(res, url, origin) {
|
|
196
|
+
sendLocalOutbox(res, url, origin, { owner = false } = {}) {
|
|
196
197
|
const id = `${origin}ap/outbox`;
|
|
197
|
-
|
|
198
|
+
// newest first; the owner's view is every message, the public one what a
|
|
199
|
+
// stranger may see
|
|
200
|
+
const outbox = this.store.read(owner ? 'outbox-own.json' : 'outbox.json', []);
|
|
198
201
|
const page = url?.searchParams?.get('page') || null;
|
|
199
202
|
const ct = { 'content-type': 'application/activity+json; charset=utf-8' };
|
|
200
203
|
if (!page) {
|
|
@@ -270,12 +273,22 @@ export class C2S {
|
|
|
270
273
|
return this.sendInbox(res, url);
|
|
271
274
|
}
|
|
272
275
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
276
|
+
// The owner, signed in, reads every message the actor produced (§5.1:
|
|
277
|
+
// the outbox is filtered by who asks). Anyone else reads the public one.
|
|
278
|
+
const owner = pathname === '/ap/outbox' && req.headers.authorization
|
|
279
|
+
? (await this.auth(req, pathname)).ok : false;
|
|
273
280
|
// Where the client surface answers on an address a stranger can reach,
|
|
274
281
|
// the pod's copies already name it and are the documents. Standalone
|
|
275
282
|
// they are not reachable by the client asking, so it is answered here.
|
|
276
283
|
const local = this.agent.publisher?.clientOrigin ? null : this.localOrigin(req);
|
|
277
284
|
if (local && pathname === '/ap/actor') return this.sendLocalActor(req, res, local);
|
|
278
|
-
if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local);
|
|
285
|
+
if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local, { owner });
|
|
286
|
+
// The owner is answered here, not sent on: a redirect loses the
|
|
287
|
+
// credential (a browser drops it across origins, and a DPoP proof names
|
|
288
|
+
// the address it was made for), so the pod would refuse them.
|
|
289
|
+
if (owner && pathname === '/ap/outbox') {
|
|
290
|
+
return this.sendLocalOutbox(res, url, this.agent.publisher.clientOrigin, { owner });
|
|
291
|
+
}
|
|
279
292
|
// The pod's copy is the document; send the reader there.
|
|
280
293
|
const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
|
|
281
294
|
res.writeHead(303, { location: target, 'cache-control': 'no-store' });
|
|
@@ -334,14 +347,22 @@ export class C2S {
|
|
|
334
347
|
visibilityOf(activity, object) {
|
|
335
348
|
const to = arr(activity.to ?? object?.to).map(idOf);
|
|
336
349
|
const cc = arr(activity.cc ?? object?.cc).map(idOf);
|
|
337
|
-
|
|
350
|
+
// Blind copies alone are still an audience: a post for them and nobody
|
|
351
|
+
// else is direct, never public.
|
|
352
|
+
const blind = arr(activity.bto ?? object?.bto).length + arr(activity.bcc ?? object?.bcc).length;
|
|
353
|
+
if (!to.length && !cc.length) return blind ? 'direct' : 'public';
|
|
338
354
|
if (to.includes(wire.PUBLIC)) return 'public';
|
|
339
355
|
if (cc.includes(wire.PUBLIC)) return 'unlisted';
|
|
340
|
-
|
|
356
|
+
// Followers named anywhere — "to alice, cc my followers" included — is a
|
|
357
|
+
// followers-only post that also reaches whoever else is named.
|
|
358
|
+
if (to.includes(this.urls.followers) || cc.includes(this.urls.followers)) return 'private';
|
|
341
359
|
return 'direct';
|
|
342
360
|
}
|
|
343
361
|
|
|
344
|
-
|
|
362
|
+
// `serial` and `at` name what this activity will make: the outbox door
|
|
363
|
+
// chose them and told the client, so they are used as given. A client
|
|
364
|
+
// posting here directly gets fresh ones.
|
|
365
|
+
async dispatch(activity, { slug = null, raw = null, serial = Date.now(), at = new Date().toISOString() } = {}) {
|
|
345
366
|
const reply = (status, body, headers = {}) => ({ status, body, headers });
|
|
346
367
|
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
347
368
|
return reply(400, { error: 'a typed ActivityStreams object is required' });
|
|
@@ -349,10 +370,12 @@ export class C2S {
|
|
|
349
370
|
// A bare object arrives without an activity around it; the server supplies
|
|
350
371
|
// the Create (§6.2.1), carrying the object's own addressing up onto it.
|
|
351
372
|
if (!ACTIVITY_TYPES.has(activity.type)) {
|
|
352
|
-
|
|
373
|
+
// Every audience field is copied up onto the Create (§6.2.1).
|
|
374
|
+
const { to, cc, bto, bcc, audience } = activity;
|
|
375
|
+
activity = { type: 'Create', object: activity, to, cc, bto, bcc, audience };
|
|
353
376
|
}
|
|
354
377
|
try {
|
|
355
|
-
return await this._dispatch(activity, { slug, raw, reply });
|
|
378
|
+
return await this._dispatch(activity, { slug, raw, reply, serial, at });
|
|
356
379
|
} catch (e) {
|
|
357
380
|
this.log(`c2s ${activity?.type}: ${e.message}`);
|
|
358
381
|
return reply(422, { error: e.message || String(e) });
|
|
@@ -366,11 +389,23 @@ export class C2S {
|
|
|
366
389
|
const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
|
|
367
390
|
.filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
|
|
368
391
|
&& a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
|
|
369
|
-
|
|
392
|
+
// `audience` is who the post is for, a group most often: delivered to and
|
|
393
|
+
// named like to and cc (§7.1).
|
|
394
|
+
return { also: pick('to', 'cc', 'audience'), deliverTo: pick('bto', 'bcc') };
|
|
370
395
|
}
|
|
371
396
|
|
|
372
|
-
async _dispatch(activity, { slug, raw, reply }) {
|
|
397
|
+
async _dispatch(activity, { slug, raw, reply, serial, at }) {
|
|
373
398
|
const agent = this.agent;
|
|
399
|
+
// §6: every post to the outbox answers 201 with the new activity's id.
|
|
400
|
+
const made = (id, body = {}) => reply(201, { id, ...body }, { location: id });
|
|
401
|
+
// What is never sent anywhere — a person's block, a pin — is still a
|
|
402
|
+
// message this actor produced, so the owner's outbox records it here.
|
|
403
|
+
const kept = (type, extra) => {
|
|
404
|
+
const id = type === 'Undo' ? wire.undoActivityId(this.urls, serial) : `${this.urls.actor}#${type.toLowerCase()}-${serial}`;
|
|
405
|
+
const act = { id, type, actor: this.urls.actor, published: at, ...extra };
|
|
406
|
+
agent.publisher.recordOwn?.(act);
|
|
407
|
+
return act.id;
|
|
408
|
+
};
|
|
374
409
|
const object = typeof activity.object === 'object' && activity.object !== null
|
|
375
410
|
? activity.object : null;
|
|
376
411
|
const objectId = idOf(activity.object);
|
|
@@ -411,6 +446,7 @@ export class C2S {
|
|
|
411
446
|
visibility,
|
|
412
447
|
spoilerText: object.summary || null,
|
|
413
448
|
sensitive: object.sensitive === true,
|
|
449
|
+
slug,
|
|
414
450
|
});
|
|
415
451
|
return reply(201,
|
|
416
452
|
{ id: wire.createActivityId(question.id), object: question.id },
|
|
@@ -445,19 +481,32 @@ export class C2S {
|
|
|
445
481
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
446
482
|
return reply(403, { error: 'not your note' });
|
|
447
483
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
484
|
+
// §6.3: a client's Update changes only what it sends, and a field sent
|
|
485
|
+
// as null is removed. What was sent is read from the bytes, because a
|
|
486
|
+
// JSON-LD reading drops the nulls that mean "remove this".
|
|
487
|
+
const sent = raw?.type === 'Update' && raw.object && typeof raw.object === 'object' ? raw.object : (object || {});
|
|
488
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(sent, k);
|
|
489
|
+
const stored = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
|
|
490
|
+
if (stored?.type && stored.type !== 'Note') {
|
|
491
|
+
await agent.publisher.updateObject(s, sent, { updated: at });
|
|
492
|
+
return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
|
|
493
|
+
}
|
|
494
|
+
const text = has('content') || sent.source?.content !== undefined
|
|
495
|
+
? String(object?.source?.content ?? object?.content ?? '') : String(s.text ?? '');
|
|
496
|
+
if (!text.trim()) return reply(422, { error: 'the edit leaves the note with no content' });
|
|
497
|
+
const attachments = has('attachment')
|
|
451
498
|
? arr(object.attachment).map((a) => ({
|
|
452
499
|
url: a?.url, mediaType: a?.mediaType,
|
|
453
500
|
...(a?.name ? { description: a.name } : {}),
|
|
454
501
|
})).filter((a) => a.url)
|
|
455
502
|
: null;
|
|
456
503
|
await agent.publisher.updateNote(s, {
|
|
457
|
-
content: text,
|
|
458
|
-
|
|
504
|
+
content: text,
|
|
505
|
+
spoilerText: has('summary') ? (object?.summary || null) : (s.spoiler || null),
|
|
506
|
+
sensitive: has('sensitive') ? object?.sensitive === true : null, attachments,
|
|
507
|
+
updated: at,
|
|
459
508
|
});
|
|
460
|
-
return
|
|
509
|
+
return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
|
|
461
510
|
}
|
|
462
511
|
|
|
463
512
|
case 'Delete': {
|
|
@@ -471,7 +520,7 @@ export class C2S {
|
|
|
471
520
|
}
|
|
472
521
|
const r = await social.deleteNote(agent, s);
|
|
473
522
|
if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
|
|
474
|
-
return
|
|
523
|
+
return made(wire.deleteActivityId(s.noteId), { object: s.noteId });
|
|
475
524
|
}
|
|
476
525
|
|
|
477
526
|
case 'Follow': {
|
|
@@ -479,31 +528,27 @@ export class C2S {
|
|
|
479
528
|
// An acct: form or bare handle resolves through WebFinger; an https
|
|
480
529
|
// IRI is fetched directly.
|
|
481
530
|
if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
|
|
482
|
-
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
|
|
531
|
+
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''), { serial });
|
|
483
532
|
const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
|
|
484
|
-
return
|
|
485
|
-
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
533
|
+
return made(rec?.followActivity?.id, { object: r.actor });
|
|
486
534
|
}
|
|
487
|
-
const doc = await social.followActor(agent, objectId);
|
|
535
|
+
const doc = await social.followActor(agent, objectId, { serial });
|
|
488
536
|
const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
|
|
489
|
-
return
|
|
490
|
-
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
537
|
+
return made(rec?.followActivity?.id, { object: doc.id });
|
|
491
538
|
}
|
|
492
539
|
|
|
493
540
|
case 'Like': {
|
|
494
541
|
const s = this.byIri(objectId);
|
|
495
542
|
if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
|
|
496
|
-
const updated = await social.favourite(agent, s);
|
|
497
|
-
return
|
|
498
|
-
updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
|
|
543
|
+
const updated = await social.favourite(agent, s, { serial });
|
|
544
|
+
return made(updated.likeActivity?.id, { object: s.noteId });
|
|
499
545
|
}
|
|
500
546
|
|
|
501
547
|
case 'Announce': {
|
|
502
548
|
const s = this.byIri(objectId);
|
|
503
549
|
if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
|
|
504
|
-
const updated = await social.reblog(agent, s);
|
|
505
|
-
return
|
|
506
|
-
updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
|
|
550
|
+
const updated = await social.reblog(agent, s, { serial });
|
|
551
|
+
return made(updated.announceActivity?.id, { object: s.noteId });
|
|
507
552
|
}
|
|
508
553
|
|
|
509
554
|
case 'Undo': {
|
|
@@ -511,39 +556,47 @@ export class C2S {
|
|
|
511
556
|
// sends one, by its type+object when it re-states it instead.
|
|
512
557
|
const inner = object;
|
|
513
558
|
const innerId = idOf(activity.object);
|
|
559
|
+
const undone = wire.undoActivityId(this.urls, serial);
|
|
514
560
|
if (inner?.type === 'Block') {
|
|
515
561
|
const target = idOf(inner.object);
|
|
516
562
|
if (!target) return reply(400, { error: 'unblock whom?' });
|
|
517
|
-
await social.unblockActor(agent, target);
|
|
518
|
-
|
|
563
|
+
await social.unblockActor(agent, target, { serial });
|
|
564
|
+
// A group's unban was sent, and recorded as it went; a person's is not.
|
|
565
|
+
if (this.store.getConfig()?.kind !== 'group') {
|
|
566
|
+
kept('Undo', { object: { type: 'Block', actor: this.urls.actor, object: target } });
|
|
567
|
+
}
|
|
568
|
+
return made(undone, { object: target });
|
|
519
569
|
}
|
|
520
570
|
const statuses = this.store.getStatuses();
|
|
521
571
|
let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
|
|
522
572
|
if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
|
|
523
573
|
if (s?.favourited) {
|
|
524
|
-
const updated = await social.unfavourite(agent, s);
|
|
525
|
-
return
|
|
574
|
+
const updated = await social.unfavourite(agent, s, { serial });
|
|
575
|
+
return made(undone, { object: updated.noteId });
|
|
526
576
|
}
|
|
527
577
|
s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
|
|
528
578
|
if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
|
|
529
579
|
if (s?.reblogged) {
|
|
530
|
-
const updated = await social.unreblog(agent, s);
|
|
531
|
-
return
|
|
580
|
+
const updated = await social.unreblog(agent, s, { serial });
|
|
581
|
+
return made(undone, { object: updated.noteId });
|
|
532
582
|
}
|
|
533
583
|
const following = this.store.getContacts().following;
|
|
534
584
|
const rec = following.find((f) => f.followActivity?.id === innerId)
|
|
535
585
|
|| (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
|
|
536
586
|
if (rec) {
|
|
537
|
-
await social.unfollowActor(agent, rec.actor);
|
|
538
|
-
return
|
|
587
|
+
await social.unfollowActor(agent, rec.actor, { serial });
|
|
588
|
+
return made(undone, { object: rec.actor });
|
|
539
589
|
}
|
|
540
590
|
return reply(422, { error: 'nothing here matches what that Undo names' });
|
|
541
591
|
}
|
|
542
592
|
|
|
543
593
|
case 'Block': {
|
|
544
594
|
if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
|
|
545
|
-
await social.blockActor(agent, objectId);
|
|
546
|
-
|
|
595
|
+
await social.blockActor(agent, objectId, { serial });
|
|
596
|
+
// A group's ban was sent to its members; a person's never goes anywhere.
|
|
597
|
+
const id = this.store.getConfig()?.kind === 'group'
|
|
598
|
+
? `${this.urls.actor}#block-${serial}` : kept('Block', { object: objectId });
|
|
599
|
+
return made(id, { object: objectId });
|
|
547
600
|
}
|
|
548
601
|
|
|
549
602
|
case 'Add':
|
|
@@ -556,7 +609,8 @@ export class C2S {
|
|
|
556
609
|
const s = this.byIri(objectId);
|
|
557
610
|
if (!s) return reply(404, { error: 'no such note here' });
|
|
558
611
|
const updated = await social.pinStatus(agent, s, activity.type === 'Add');
|
|
559
|
-
return
|
|
612
|
+
return made(kept(activity.type, { object: updated.noteId, target: this.urls.featured }),
|
|
613
|
+
{ object: updated.noteId, pinned: !!updated.pinned });
|
|
560
614
|
}
|
|
561
615
|
|
|
562
616
|
case 'Accept':
|
|
@@ -566,10 +620,10 @@ export class C2S {
|
|
|
566
620
|
const requester = object?.actor ? idOf(object.actor) : objectId;
|
|
567
621
|
if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
|
|
568
622
|
const r = activity.type === 'Accept'
|
|
569
|
-
? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
|
|
570
|
-
: await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
|
|
623
|
+
? await social.admitRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }))
|
|
624
|
+
: await social.refuseRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }));
|
|
571
625
|
if (r.error) return reply(404, { error: r.error });
|
|
572
|
-
return
|
|
626
|
+
return made(`${this.urls.actor}#${activity.type.toLowerCase()}-${serial}`, { object: requester });
|
|
573
627
|
}
|
|
574
628
|
|
|
575
629
|
case 'Move':
|
|
@@ -255,6 +255,7 @@ export async function handle(api, ctx) {
|
|
|
255
255
|
if (req.method === 'POST' && !b.domains.includes(domain)) b.domains.push(domain);
|
|
256
256
|
if (req.method === 'DELETE') b.domains = b.domains.filter(d => d !== domain);
|
|
257
257
|
api.store.setBlocklist(b);
|
|
258
|
+
Promise.resolve(api.agent?.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
258
259
|
return send(200, {});
|
|
259
260
|
}
|
|
260
261
|
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -187,7 +187,21 @@ export class Deliverer {
|
|
|
187
187
|
return out;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// A fresh activity of this actor's, for the outbox its owner reads. Never
|
|
191
|
+
// allowed to stop a delivery; a retry from the queue is not fresh.
|
|
192
|
+
_sent(activity) {
|
|
193
|
+
try { this.onSent?.(activity); } catch (e) { this.log(`owner's outbox record: ${e.message}`); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// An inbox on a blocked domain is never delivered to, whoever still lists it.
|
|
197
|
+
_blockedInbox(inbox) {
|
|
198
|
+
try { return !!this.store.getBlocklist?.().domains.length && this.store.isBlocked?.(new URL(inbox).origin + '/'); }
|
|
199
|
+
catch { return false; }
|
|
200
|
+
}
|
|
201
|
+
|
|
190
202
|
async deliver(inbox, activity) {
|
|
203
|
+
this._sent(activity);
|
|
204
|
+
if (this._blockedInbox(inbox)) return;
|
|
191
205
|
// Proved before anything else, so the copy that goes on the queue is the
|
|
192
206
|
// copy that was signed — a retry days later must not post a bare activity.
|
|
193
207
|
const signed = await this.proofed(activity);
|
|
@@ -250,13 +264,15 @@ export class Deliverer {
|
|
|
250
264
|
}
|
|
251
265
|
|
|
252
266
|
async deliverToAll(inboxes, activity) {
|
|
267
|
+
this._sent(activity);
|
|
253
268
|
// Proved once for the whole fan-out: every recipient gets the same bytes,
|
|
254
269
|
// and one signature is computed rather than one per follower.
|
|
255
270
|
const signed = await this.proofed(activity);
|
|
256
271
|
// Shared inboxes deduplicate fan-out to the same server. Sent in batches
|
|
257
272
|
// of batchSize, and a host found cooling by one batch is not asked again
|
|
258
273
|
// by the next.
|
|
259
|
-
const targets = [...new Set(inboxes)].
|
|
274
|
+
const targets = [...new Set(inboxes)].filter((inbox) => !this._blockedInbox(inbox))
|
|
275
|
+
.map((inbox) => ({ inbox, activity: signed }));
|
|
260
276
|
for (let i = 0; i < targets.length; i += this.batchSize) {
|
|
261
277
|
const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
|
|
262
278
|
if (!chunk.length) continue;
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { dropFollower } from '../store.mjs';
|
|
6
6
|
import { emojisOf, pollOf, isContentType, trimActivity, reactionOf, quoteOf, quotePolicyOf, authorOf } from './activity.mjs';
|
|
7
7
|
|
|
8
|
+
// How long a follower's refollow waits before it is answered again.
|
|
9
|
+
const REACCEPT_MS = 60 * 60 * 1000;
|
|
10
|
+
|
|
8
11
|
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
|
|
9
12
|
|
|
10
13
|
// §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
|
|
@@ -98,6 +101,18 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
|
|
|
98
101
|
await intake.republish({ followers: true });
|
|
99
102
|
intake.log(`new follower: ${actor}`);
|
|
100
103
|
}
|
|
104
|
+
// A refollow from someone already following is answered again — their server
|
|
105
|
+
// may have lost our Accept — but not every time: the same Follow sent over and
|
|
106
|
+
// over would otherwise make us send, and record, an Accept for each one.
|
|
107
|
+
if (existing) {
|
|
108
|
+
const c = intake.store.getContacts();
|
|
109
|
+
const f = c.followers.find(x => x.actor === actor);
|
|
110
|
+
if (f?.acceptedAt && Date.now() - Date.parse(f.acceptedAt) < REACCEPT_MS) {
|
|
111
|
+
intake.log(`refollow from ${actor} — already accepted within the hour, not answered again`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (f) { f.acceptedAt = new Date().toISOString(); intake.store.setContacts(c); }
|
|
115
|
+
}
|
|
101
116
|
const { acceptActivity } = await import('../wire.mjs');
|
|
102
117
|
await intake.deliverer.deliver(doc.inbox,
|
|
103
118
|
acceptActivity({ urls: intake.urls, followActivity: activity, serial: intake.serial++ }));
|
|
@@ -370,8 +385,9 @@ export async function onDelete(intake, activity, actor) {
|
|
|
370
385
|
await intake.forget(s, { collect: retracted });
|
|
371
386
|
}
|
|
372
387
|
if (retracted.length) {
|
|
373
|
-
const gone = new Set(retracted);
|
|
374
|
-
await intake.publisher.unrecordOutbox(i => gone.has(i?.id)
|
|
388
|
+
const gone = new Set(retracted.map(u => u.object.id));
|
|
389
|
+
await intake.publisher.unrecordOutbox(i => gone.has(i?.id),
|
|
390
|
+
{ record: (was) => (intake.config.private ? null : retracted.filter(u => was.includes(u.object.id))) });
|
|
375
391
|
}
|
|
376
392
|
// Both: an account deletion drops them from followers AND following.
|
|
377
393
|
await intake.republish({ followers: true, following: true });
|
|
@@ -175,7 +175,9 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
175
175
|
// Marked carried before recorded: a failed outbox write costs one missing
|
|
176
176
|
// entry, a failed status write would carry the same post twice.
|
|
177
177
|
intake.store.updateStatus(noteId, { announcedAt: new Date().toISOString(), announceActivity: act });
|
|
178
|
-
|
|
178
|
+
// A private group's carries are written for its members alone, so they go on
|
|
179
|
+
// its owner's full outbox (recorded as sent) and never on the public one.
|
|
180
|
+
if (!intake.config.private) await intake.publisher.recordOutbox(act);
|
|
179
181
|
intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
|
|
180
182
|
intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
181
183
|
// What the group carried is what the group's conversation holds. A forum
|
|
@@ -656,10 +656,33 @@ export class Intake {
|
|
|
656
656
|
// document, not our reading of it); the graph is what it decides from.
|
|
657
657
|
// Returns null when published, else the reason it was not — a dead letter.
|
|
658
658
|
async ownerPostFrom(activity, raw, receipt) {
|
|
659
|
+
// The receipt names the body it vouches for; a body it does not name is
|
|
660
|
+
// not the owner's. A body already handled — a replayed request, or an item
|
|
661
|
+
// the pod would not let us delete — is not published twice.
|
|
662
|
+
const hash = raw ? await sha256Hex(raw) : null;
|
|
663
|
+
if (receipt.hash && receipt.hash !== hash) return 'owner post refused: the body is not the one its receipt names';
|
|
664
|
+
const seen = this.store.read('c2s-seen.json', []);
|
|
665
|
+
if (hash && seen.includes(hash)) {
|
|
666
|
+
this.log('owner post already handled — not published again');
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
659
669
|
let asSent = null;
|
|
660
670
|
try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
|
|
661
|
-
const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null
|
|
662
|
-
|
|
671
|
+
const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null,
|
|
672
|
+
// What the door told the client this will be called, when it said.
|
|
673
|
+
...(receipt.serial ? { serial: receipt.serial } : {}), ...(receipt.at ? { at: receipt.at } : {}) });
|
|
674
|
+
// Handled, one way or the other: a second sight of it changes nothing. A
|
|
675
|
+
// throw above is not handled, and is tried again.
|
|
676
|
+
if (hash) this.store.write('c2s-seen.json', [hash, ...this.store.read('c2s-seen.json', [])].slice(0, C2S_SEEN_MAX));
|
|
677
|
+
if (!r || r.status >= 300) {
|
|
678
|
+
const why = r?.body?.error || 'no reason was given';
|
|
679
|
+
// The door already answered the app "created"; the owner is the one
|
|
680
|
+
// left to tell.
|
|
681
|
+
await this.publisher.noteToSelf?.(`An app asked your account to publish ${article(activity.type)}, `
|
|
682
|
+
+ `and your account refused it: ${why}. Nothing was sent.`)
|
|
683
|
+
.catch(e => this.log(`could not tell the owner their post was refused: ${e.message}`));
|
|
684
|
+
return `owner post refused (${r?.status || '?'}): ${why}`;
|
|
685
|
+
}
|
|
663
686
|
this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
|
|
664
687
|
return null;
|
|
665
688
|
}
|
|
@@ -699,3 +722,11 @@ export class Intake {
|
|
|
699
722
|
retract(...a) { return notes.retract(this, ...a); }
|
|
700
723
|
addReply(...a) { return notes.addReply(this, ...a); }
|
|
701
724
|
}
|
|
725
|
+
|
|
726
|
+
// "a Like", "an Update": the kind of activity, as a sentence says it.
|
|
727
|
+
const article = (type) => `${/^[AEIOU]/u.test(String(type)) ? 'an' : 'a'} ${type || 'post'}`;
|
|
728
|
+
|
|
729
|
+
// Bodies the outbox door handed over that were handled, newest first.
|
|
730
|
+
const C2S_SEEN_MAX = 500;
|
|
731
|
+
const sha256Hex = async (text) => [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256',
|
|
732
|
+
new TextEncoder().encode(text)))].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
@@ -91,7 +91,11 @@ export async function maybeForward(intake, activity) {
|
|
|
91
91
|
.filter(Boolean))];
|
|
92
92
|
if (!inboxes.length) return;
|
|
93
93
|
intake._forwardBudget -= 1;
|
|
94
|
-
|
|
94
|
+
// Blind copies are for their sender's delivery alone and never travel on
|
|
95
|
+
// (§6, §7.1), from the activity or its object.
|
|
96
|
+
const strip = (o) => { if (!o || typeof o !== 'object') return o; const { bto, bcc, ...rest } = o; return rest; }; // eslint-disable-line no-unused-vars
|
|
97
|
+
const onward = { ...strip(activity), ...(activity.object && typeof activity.object === 'object' ? { object: strip(activity.object) } : {}) };
|
|
98
|
+
await intake.deliverer.deliverToAll(inboxes, onward);
|
|
95
99
|
intake.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
|
|
96
100
|
intake.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
|
|
97
101
|
} catch (e) {
|
|
@@ -319,10 +323,13 @@ export async function retract(intake, noteId, { collect = null } = {}) {
|
|
|
319
323
|
if (!s.announceActivity) throw new Error('that post was never carried');
|
|
320
324
|
const { undoActivity } = await import('../wire.mjs');
|
|
321
325
|
const inboxes = intake.announceTargets(s.actor);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
const undo = undoActivity({ urls: intake.urls, activity: s.announceActivity, serial: intake.serial++ });
|
|
327
|
+
await intake.deliverer.deliverToAll(inboxes, undo);
|
|
328
|
+
// The Undo takes the carry's place in the outbox, so a reader catching up
|
|
329
|
+
// from it learns the carry was withdrawn.
|
|
330
|
+
if (collect) collect.push(undo);
|
|
331
|
+
else await intake.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id,
|
|
332
|
+
{ record: () => (intake.config.private ? null : undo) });
|
|
326
333
|
intake.store.updateStatus(noteId, {
|
|
327
334
|
announcedAt: undefined, announceActivity: undefined, retractedAt: new Date().toISOString(),
|
|
328
335
|
});
|