fedipod-server 0.11.0 → 0.13.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 +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// activities.mjs — one handler per inbound activity type. Each is reached
|
|
2
|
+
// from Intake.handle() after the sender passed the door checks, and returns a
|
|
3
|
+
// rejection reason or nothing.
|
|
4
|
+
|
|
5
|
+
import { dropFollower } from '../store.mjs';
|
|
6
|
+
import { emojisOf, pollOf, isContentType, trimActivity } from './activity.mjs';
|
|
7
|
+
|
|
8
|
+
// §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
|
|
9
|
+
// to/from the collection named in `target` — but only a collection we own AND
|
|
10
|
+
// that the sender is authorised to modify. No remote is granted write to our
|
|
11
|
+
// collections (membership is Follow/Undo, pins are ours to set), so there is
|
|
12
|
+
// nothing an inbound Add or Remove may change here. It is a valid activity,
|
|
13
|
+
// not garbage: acknowledge it, make no change, and never dead-letter it.
|
|
14
|
+
export function onAddRemove(intake, activity, actor) {
|
|
15
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
16
|
+
const ours = target && [intake.urls.followers, intake.urls.following, intake.urls.featured]
|
|
17
|
+
.filter(Boolean).includes(target);
|
|
18
|
+
intake.log(ours
|
|
19
|
+
? `${activity.type} from ${actor} targets our ${target} — no remote may modify it; acknowledged`
|
|
20
|
+
: `${activity.type} from ${actor} targets ${target || 'no collection of ours'}; nothing here to change`);
|
|
21
|
+
return; // accepted, no side effect
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function onFollow(intake, activity, actor, { trusted = false } = {}) {
|
|
25
|
+
const doc = await intake.fetchAP(actor); // origin must vouch for the actor
|
|
26
|
+
if (!doc) return `actor fetch failed (${actor})`;
|
|
27
|
+
if (doc.id !== actor) return `actor id mismatch (${actor} vs ${doc.id})`;
|
|
28
|
+
if (!doc.inbox) return `actor has no inbox (${actor})`;
|
|
29
|
+
const contacts = intake.store.getContacts();
|
|
30
|
+
const existing = contacts.followers.find(f => f.actor === actor);
|
|
31
|
+
// NOTHING binds a delivered Follow to the actor it names. LDN bodies carry
|
|
32
|
+
// no signature, and unlike Create, Delete and Update there is no object at
|
|
33
|
+
// the origin to re-fetch and compare — dereferencing the actor proves only
|
|
34
|
+
// that the actor EXISTS. So anyone at all could Append a Follow naming
|
|
35
|
+
// anyone at all, and we would sign an Accept, deliver it to that person,
|
|
36
|
+
// and send them everything published from then on.
|
|
37
|
+
//
|
|
38
|
+
// Until deliveries terminate somewhere their signature survives, a follow
|
|
39
|
+
// we cannot verify is a REQUEST, waiting in the same queue a gated group
|
|
40
|
+
// uses. The requester's client shows "Requested", which is the ordinary
|
|
41
|
+
// locked-account state that manuallyApprovesFollowers tells it to expect.
|
|
42
|
+
// `autoAcceptFollows: true` in config restores the old behaviour.
|
|
43
|
+
// A GROUP is left alone: `approveJoins: false` is its operator saying, in
|
|
44
|
+
// as many words, that anyone may join, and mute/eject are the remedy there.
|
|
45
|
+
// A person has no such setting, so this is their default.
|
|
46
|
+
//
|
|
47
|
+
// A gateway-verified Follow (trust mode) is no longer unverifiable — the
|
|
48
|
+
// door proved the sender — so it does not need the OK that unverifiability
|
|
49
|
+
// alone demanded. An explicit `approveJoins` still holds: verified or not,
|
|
50
|
+
// the operator asked to see joins.
|
|
51
|
+
const unverifiedNeedsOk = intake.config.kind !== 'group' && !intake.config.autoAcceptFollows && !trusted;
|
|
52
|
+
const mustApprove = intake.config.approveJoins || unverifiedNeedsOk;
|
|
53
|
+
if (mustApprove && !existing) {
|
|
54
|
+
const reqs = intake.store.getRequests();
|
|
55
|
+
if (!reqs.some(r => r.actor === actor)) {
|
|
56
|
+
reqs.unshift({
|
|
57
|
+
actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox,
|
|
58
|
+
activity: trimActivity(activity), at: new Date().toISOString(),
|
|
59
|
+
});
|
|
60
|
+
intake.store.setRequests(reqs.slice(0, 500));
|
|
61
|
+
intake.store.addNotification({ type: 'follow-request', actor });
|
|
62
|
+
await intake.republish({ pending: true });
|
|
63
|
+
}
|
|
64
|
+
intake.log(`join requested: ${actor}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (existing) {
|
|
68
|
+
// Deliberately NOT updating followId. An inbound Follow is unverifiable —
|
|
69
|
+
// that is what the queue above exists for — so letting one rewrite the id
|
|
70
|
+
// of a follower we already hold hands an attacker the exact value onUndo
|
|
71
|
+
// matches on: POST a Follow naming any follower in the published
|
|
72
|
+
// collection, then POST an Undo naming the id you just chose, and they are
|
|
73
|
+
// gone permanently. A genuine refollow needs nothing from us but the
|
|
74
|
+
// Accept below, which is idempotent.
|
|
75
|
+
} else {
|
|
76
|
+
// A Bluesky member who bridges later arrives here as a second, different
|
|
77
|
+
// actor: the bridge follows on their behalf from bsky.brid.gy/ap/<did>,
|
|
78
|
+
// while the native join is recorded under bsky.app/profile/<did>. Left
|
|
79
|
+
// alone that is one person listed twice, carried twice, and ejectable
|
|
80
|
+
// only half at a time. The bridged record supersedes the native one —
|
|
81
|
+
// it reaches the fediverse side, which the native one never could.
|
|
82
|
+
const bridgedDid = /^https:\/\/bsky\.brid\.gy\/ap\/(did:[^/]+)$/.exec(actor)?.[1];
|
|
83
|
+
if (bridgedDid) {
|
|
84
|
+
const before = contacts.followers.length;
|
|
85
|
+
contacts.followers = contacts.followers.filter(f => f.bsky?.did !== bridgedDid);
|
|
86
|
+
if (contacts.followers.length < before) {
|
|
87
|
+
intake.log(`bluesky member ${bridgedDid} is bridged now — the native record gives way to it`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
contacts.followers.push({
|
|
91
|
+
actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox, followId: activity.id,
|
|
92
|
+
...(bridgedDid ? { bsky: { did: bridgedDid, bridged: true } } : {}),
|
|
93
|
+
});
|
|
94
|
+
intake.store.setContacts(contacts);
|
|
95
|
+
intake.store.addNotification({ type: 'follow', actor });
|
|
96
|
+
await intake.republish({ followers: true });
|
|
97
|
+
intake.log(`new follower: ${actor}`);
|
|
98
|
+
}
|
|
99
|
+
const { acceptActivity } = await import('../wire.mjs');
|
|
100
|
+
await intake.deliverer.deliver(doc.inbox,
|
|
101
|
+
acceptActivity({ urls: intake.urls, followActivity: activity, serial: intake.serial++ }));
|
|
102
|
+
intake.log(`Accept sent → ${doc.inbox}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function onUndo(intake, activity, actor, { trusted = false } = {}) {
|
|
106
|
+
// AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
|
|
107
|
+
// id we stored. Reading `.type` off a string gives undefined, so the whole
|
|
108
|
+
// Undo was dropped — silently, since handle() reads that as handled, so no
|
|
109
|
+
// dead letter was kept and the item was DELETEd. The follower stayed, we
|
|
110
|
+
// kept delivering to them, and their server had recorded the unfollow as
|
|
111
|
+
// done and would never resend. Only a TYPED non-Follow is not ours.
|
|
112
|
+
if (typeof activity.object === 'object' && activity.object?.type
|
|
113
|
+
&& activity.object.type !== 'Follow') return;
|
|
114
|
+
// And it must NAME something. Widening the type test to admit a bare IRI
|
|
115
|
+
// also admitted `object: undefined`, `null` and `{}` — which land on the
|
|
116
|
+
// no-followId carve-out below and evict, which is the very hole the
|
|
117
|
+
// followId check was added to close. An Undo that identifies nothing is
|
|
118
|
+
// not an Undo of ours.
|
|
119
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
120
|
+
// A gateway-verified Undo need not name a stored id — the door proved the
|
|
121
|
+
// sender, so an Undo{Follow} that names us as its object is enough. An
|
|
122
|
+
// unverified one must still identify something (the eviction-hole guard).
|
|
123
|
+
if (!named && !trusted) return;
|
|
124
|
+
// Deliveries arrive unordered: an Undo may land AFTER the refollow it
|
|
125
|
+
// predates. It names the Follow id it revokes — only honor it when it
|
|
126
|
+
// matches the follow we currently hold for that actor.
|
|
127
|
+
const undoneId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
128
|
+
const contacts = intake.store.getContacts();
|
|
129
|
+
const rec = contacts.followers.find(f => f.actor === actor);
|
|
130
|
+
// Withdrawing a request that was never answered: drop it, or it sits in the
|
|
131
|
+
// operator's queue forever asking about someone who left.
|
|
132
|
+
if (!rec) {
|
|
133
|
+
const reqs = intake.store.getRequests();
|
|
134
|
+
const pending = reqs.find(r => r.actor === actor);
|
|
135
|
+
if (pending) {
|
|
136
|
+
// Bound the same way the follower eviction below is, and for the same
|
|
137
|
+
// reason: `actor` is a field in an unsigned body, so without a match
|
|
138
|
+
// anyone could withdraw anyone else's waiting request. The victim's
|
|
139
|
+
// server believes the Follow is still pending and never resends, so
|
|
140
|
+
// they simply never get followed and nobody sees why.
|
|
141
|
+
const theirs = pending.activity?.id;
|
|
142
|
+
if (!trusted && (!theirs || named !== theirs)) {
|
|
143
|
+
intake.log(`Undo from ${actor} does not name the request we hold — ignored`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
intake.store.setRequests(reqs.filter(r => r.actor !== actor));
|
|
147
|
+
await intake.republish({ pending: true });
|
|
148
|
+
intake.log(`join request withdrawn: ${actor}`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// An Undo must NAME the Follow it revokes, and name the one we hold.
|
|
153
|
+
//
|
|
154
|
+
// The follow id is the ONLY thing binding an Undo to the follower. LDN
|
|
155
|
+
// bodies carry no signature, and unlike every other inbound type this path
|
|
156
|
+
// dereferences nothing, so there is no origin to disagree. Matching works
|
|
157
|
+
// because the id was chosen by their server and delivered in a Follow we
|
|
158
|
+
// accepted: we publish the followers collection, but never the ids.
|
|
159
|
+
//
|
|
160
|
+
// Which means a record with NO id cannot be matched at all — and the
|
|
161
|
+
// carve-out that used to let those through turned "we cannot tell" into
|
|
162
|
+
// "anyone may evict". reconcileFollowers writes exactly such records when a
|
|
163
|
+
// restored machine recovers its followers from the pod, so after a restore
|
|
164
|
+
// every follower could be removed by one unauthenticated POST, permanently:
|
|
165
|
+
// dropFollower leaves a mark and the next reconcile will not bring them
|
|
166
|
+
// back, their server recorded no unfollow so it never resends, and neither
|
|
167
|
+
// side has anything to notice.
|
|
168
|
+
//
|
|
169
|
+
// Unmatchable is refused now. The cost is a follower who really did leave
|
|
170
|
+
// staying on the list until the operator ejects them, which is the right way
|
|
171
|
+
// round: `eject` is one command, and the alternative was silent, permanent,
|
|
172
|
+
// and available to anyone.
|
|
173
|
+
// A gateway-verified Undo carries the sender's proof, so it is honored on
|
|
174
|
+
// its own — the followId match below exists only because an UNVERIFIED Undo
|
|
175
|
+
// is otherwise unbindable. A verified one needs no such crutch.
|
|
176
|
+
if (trusted) {
|
|
177
|
+
dropFollower(contacts, actor, 'undo-follow');
|
|
178
|
+
intake.store.setContacts(contacts);
|
|
179
|
+
await intake.republish({ followers: true });
|
|
180
|
+
intake.log(`unfollowed by ${actor} (gateway-verified)`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!rec.followId) {
|
|
184
|
+
intake.log(`Undo from ${actor} cannot be matched — this follower was `
|
|
185
|
+
+ `${rec.recovered ? 'recovered from the pod' : 'recorded before follow ids were kept'}, `
|
|
186
|
+
+ `so its follow id is unknown. Ignored; \`fedipod eject ${actor}\` if they did leave.`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (undoneId !== rec.followId) {
|
|
190
|
+
intake.log(`Undo from ${actor} does not name the follow we hold `
|
|
191
|
+
+ `(revokes ${undoneId || 'nothing'}, current is ${rec.followId}) — ignored`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
dropFollower(contacts, actor, 'undo-follow');
|
|
195
|
+
intake.store.setContacts(contacts);
|
|
196
|
+
await intake.republish({ followers: true });
|
|
197
|
+
intake.log(`unfollowed by ${actor}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function onCreate(intake, activity, actor) {
|
|
201
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
202
|
+
if (!objectId) return 'Create without object id';
|
|
203
|
+
if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
204
|
+
if (!intake.sameIdentity(objectId, actor)) return `object/actor identity mismatch (${objectId})`;
|
|
205
|
+
// The delivered copy is untrusted for CONTENT, but its addressing is
|
|
206
|
+
// enough to decide whether to bother fetching the origin's copy.
|
|
207
|
+
const envelope = typeof activity.object === 'object' ? { ...activity, ...activity.object } : activity;
|
|
208
|
+
if (!intake.concernsUs(envelope, actor)) return `not addressed to us (${objectId})`;
|
|
209
|
+
// The check onAnnounce has had all along. A re-delivered Create — a remote
|
|
210
|
+
// retry, a group fan-out, or our own sweep seeing an item whose DELETE was
|
|
211
|
+
// refused — cost a fresh signed GET to the origin and rewrote the private
|
|
212
|
+
// RDF note every time, because addStatus only dedupes AFTER the deref.
|
|
213
|
+
// Gated around the INGEST alone: a group must still reach amplify below,
|
|
214
|
+
// which is separately idempotent on announcedAt.
|
|
215
|
+
// Only a status that came from an INGEST counts as already done. TagFeed
|
|
216
|
+
// writes a bare `kind:'tag'` row straight into the index — no pod RDF note,
|
|
217
|
+
// no mention notification, no replies-collection entry — so treating that
|
|
218
|
+
// as ingested loses all three when the same note is then delivered to us.
|
|
219
|
+
const ingested = intake.store.getStatuses()
|
|
220
|
+
.some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
|
|
221
|
+
if (!ingested) {
|
|
222
|
+
const rejected = await intake.ingestNote(objectId, actor);
|
|
223
|
+
if (rejected) return rejected;
|
|
224
|
+
}
|
|
225
|
+
// A group carries its members' posts onward. Only reached from Create, so an
|
|
226
|
+
// inbound Announce is never re-announced. The activity is passed through
|
|
227
|
+
// untouched — FEP-1b12 wants the original wrapped, not a summary of it.
|
|
228
|
+
if (intake.config.kind === 'group') await intake.amplify(objectId, { activity });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A boost: ingest the boosted note when the booster is someone we follow —
|
|
232
|
+
// that's what following means, their boosts widen the timeline. Anything
|
|
233
|
+
// else is unsolicited and only logged.
|
|
234
|
+
// A group we follow announces a Delete: the carrier moderating away a post
|
|
235
|
+
// it carried. Honored only within what the carry itself established — the
|
|
236
|
+
// announcer is a group we follow AND the post reached us via that same
|
|
237
|
+
// group — so no new party is trusted and nothing is dereferenced. Our own
|
|
238
|
+
// posts are never removed by anyone's moderation.
|
|
239
|
+
export async function onAnnouncedDelete(intake, actor, del) {
|
|
240
|
+
const followed = intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
241
|
+
if (!followed) { intake.log(`announced Delete from unfollowed ${actor} — ignored`); return; }
|
|
242
|
+
const targetId = typeof del.object === 'string' ? del.object : del.object?.id;
|
|
243
|
+
if (!targetId) return 'announced Delete without an object';
|
|
244
|
+
const s = intake.store.getStatuses().find(x => x.noteId === targetId);
|
|
245
|
+
if (!s || s.kind === 'post' || s.via !== actor) return;
|
|
246
|
+
await intake.forget(s);
|
|
247
|
+
intake.log(`moderated away by ${actor}: ${targetId}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function onAnnounce(intake, activity, actor, objectId) {
|
|
251
|
+
if (!objectId) return 'Announce without object id';
|
|
252
|
+
const followed = intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
253
|
+
if (!followed) { intake.log(`Announce from unfollowed ${actor} — ignored`); return; }
|
|
254
|
+
if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
255
|
+
const existing = intake.store.getStatuses().find(s => s.noteId === objectId);
|
|
256
|
+
if (existing) {
|
|
257
|
+
// Known, but possibly as a lesser kind — a stranger's mention, a tag or
|
|
258
|
+
// search mirror — none of which the home timeline shows. A carry from
|
|
259
|
+
// someone we follow is exactly what promotes it there.
|
|
260
|
+
if (!['timeline', 'post'].includes(existing.kind)) {
|
|
261
|
+
intake.store.updateStatus(objectId, { kind: 'timeline', via: actor });
|
|
262
|
+
intake.log(`promoted to timeline (carried by ${actor}): ${objectId}`);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
return intake.ingestNote(objectId, actor, { via: actor });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Mastodon sends these constantly; ignoring them left deleted posts standing
|
|
270
|
+
// for good. Two guards, because a forged Delete would otherwise erase anyone's
|
|
271
|
+
// content: it must come from the object's own origin, and the object must
|
|
272
|
+
// really be gone there. An origin we cannot reach is a retry, never a delete.
|
|
273
|
+
export async function onDelete(intake, activity, actor) {
|
|
274
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
275
|
+
if (!objectId) return 'Delete without object id';
|
|
276
|
+
if (!intake.sameIdentity(objectId, actor)) return `Delete crosses identities (${objectId})`;
|
|
277
|
+
// `objectId === actor` is NOT evidence we care: it is true of EVERY account
|
|
278
|
+
// deletion, and Mastodon broadcasts those constantly. Taking it as known
|
|
279
|
+
// meant a signed dereference to a stranger's server for each one.
|
|
280
|
+
if (!intake.known(objectId)) return; // nothing of ours to remove
|
|
281
|
+
const gone = await intake.isGone(objectId);
|
|
282
|
+
if (gone === null) throw new Error(`cannot confirm ${objectId} is gone — will retry`);
|
|
283
|
+
if (!gone) return `Delete for something still published (${objectId})`;
|
|
284
|
+
|
|
285
|
+
if (objectId === actor) { // the account itself
|
|
286
|
+
const contacts = intake.store.getContacts();
|
|
287
|
+
dropFollower(contacts, actor, 'account-deleted');
|
|
288
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
289
|
+
intake.store.setContacts(contacts);
|
|
290
|
+
// One publish for the lot. Each forget() used to run its own
|
|
291
|
+
// unrecordOutbox, and each of those republished the outbox — so a group
|
|
292
|
+
// that had carried M of this actor's posts paid M full page sweeps for a
|
|
293
|
+
// single inbox item. The Undo deliveries stay per-Announce, because each
|
|
294
|
+
// Announce needs its own; only the pod write is collected.
|
|
295
|
+
const retracted = [];
|
|
296
|
+
for (const s of intake.store.getStatuses().filter(s => s.actor === actor)) {
|
|
297
|
+
await intake.forget(s, { collect: retracted });
|
|
298
|
+
}
|
|
299
|
+
if (retracted.length) {
|
|
300
|
+
const gone = new Set(retracted);
|
|
301
|
+
await intake.publisher.unrecordOutbox(i => gone.has(i?.id));
|
|
302
|
+
}
|
|
303
|
+
// Both: an account deletion drops them from followers AND following.
|
|
304
|
+
await intake.republish({ followers: true, following: true });
|
|
305
|
+
intake.log(`account deleted upstream: ${actor}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const s = intake.store.getStatuses().find(x => x.noteId === objectId);
|
|
309
|
+
if (s) await intake.forget(s);
|
|
310
|
+
intake.log(`deleted upstream: ${objectId}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// An edited post, or a changed profile. Verified the only way we can: by
|
|
314
|
+
// refetching at the origin and believing that, not the delivered copy.
|
|
315
|
+
export async function onUpdate(intake, activity, actor) {
|
|
316
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
317
|
+
if (!objectId) return 'Update without object id';
|
|
318
|
+
if (!intake.sameIdentity(objectId, actor)) return `Update crosses identities (${objectId})`;
|
|
319
|
+
if (objectId === actor) { // display name, avatar, bio
|
|
320
|
+
// The same guard onDelete has, for the same reason: the inbox is
|
|
321
|
+
// public-Append, so without it anyone can name any host and make us spend
|
|
322
|
+
// a signed GET on it — and because the failure below THROWS rather than
|
|
323
|
+
// returning a rejection, one planted item buys five of them, plus five
|
|
324
|
+
// pod reads and the head of the inbox held for five sweeps.
|
|
325
|
+
if (!intake.known(actor)) return; // nothing of ours to update
|
|
326
|
+
const doc = await intake.fetchAP(actor);
|
|
327
|
+
if (!doc) throw new Error(`cannot refetch ${actor} — will retry`);
|
|
328
|
+
intake.store.cacheActor(actor, doc); // fetchAP caches Persons; Groups too
|
|
329
|
+
intake.log(`profile updated: ${actor}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const s = intake.store.getStatuses().find(x => x.noteId === objectId);
|
|
333
|
+
if (!s) return; // not one we hold
|
|
334
|
+
const note = await intake.fetchAP(objectId);
|
|
335
|
+
if (!note) throw new Error(`cannot refetch ${objectId} — will retry`);
|
|
336
|
+
if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
|
|
337
|
+
const { attachmentsOf, titledContent } = await import('../wire.mjs');
|
|
338
|
+
const content = titledContent(note);
|
|
339
|
+
const attachments = attachmentsOf(note);
|
|
340
|
+
const freshPoll = pollOf(note);
|
|
341
|
+
const freshEmojis = emojisOf(note);
|
|
342
|
+
intake.store.updateStatus(objectId, {
|
|
343
|
+
content, ...(attachments.length ? { attachments } : {}),
|
|
344
|
+
emojis: freshEmojis.length ? freshEmojis : undefined,
|
|
345
|
+
// The edit's own stamp when the note carries one; tallies and the
|
|
346
|
+
// content warning follow the edit too. A poll refresh keeps our vote.
|
|
347
|
+
editedAt: note.updated || new Date().toISOString(),
|
|
348
|
+
spoiler: note.summary ? String(note.summary).replace(/<[^>]*>/g, '') : undefined,
|
|
349
|
+
...(freshPoll ? {
|
|
350
|
+
poll: { ...freshPoll, voted: !!s.poll?.voted, ownVotes: s.poll?.ownVotes || [] },
|
|
351
|
+
} : {}),
|
|
352
|
+
});
|
|
353
|
+
intake.log(`edited upstream: ${objectId}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The other answer to a Follow, and it was dropped on the floor. Their server
|
|
357
|
+
// has recorded that we do not follow them; ours went on saying we did, and
|
|
358
|
+
// published it — so the two disagreed permanently, and a retry would never
|
|
359
|
+
// come because as far as they are concerned the question was answered.
|
|
360
|
+
//
|
|
361
|
+
// It has to answer the Follow we actually SENT. Only the type was checked, so
|
|
362
|
+
// one Append per account you follow — from anyone, naming no particular
|
|
363
|
+
// follow — severed every one of them at once, and silently: their server
|
|
364
|
+
// never hears about it, so nothing ever retries and nothing looks wrong until
|
|
365
|
+
// the timeline goes quiet. `followActivity` is stored by followActor
|
|
366
|
+
// (lib/social.mjs) for exactly this kind of comparison.
|
|
367
|
+
export async function onReject(intake, activity, actor, { trusted = false } = {}) {
|
|
368
|
+
if (activity.object?.type && activity.object.type !== 'Follow') return;
|
|
369
|
+
const contacts = intake.store.getContacts();
|
|
370
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
371
|
+
if (!rec) return; // nothing of ours to undo
|
|
372
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
373
|
+
const ours = rec.followActivity?.id;
|
|
374
|
+
// A gateway receipt bound to this actor is the other way to believe it —
|
|
375
|
+
// the door checked a signature and said whose (see receiptVouchesFor).
|
|
376
|
+
if (!trusted) {
|
|
377
|
+
if (!ours) {
|
|
378
|
+
intake.log(`Reject from ${actor}: no follow id on record to match it against — ignored`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (named !== ours) {
|
|
382
|
+
intake.log(`Reject from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
387
|
+
intake.store.setContacts(contacts);
|
|
388
|
+
await intake.republish({ following: true, pending: true });
|
|
389
|
+
intake.log(`follow rejected by ${actor}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Someone we follow has moved. Their server will stop delivering from the old
|
|
393
|
+
// actor, so without this we keep an entry that can never produce another post
|
|
394
|
+
// and never learn where they went. The new account is not followed
|
|
395
|
+
// automatically — that is a Follow only the owner should send — but it is
|
|
396
|
+
// recorded and raised, so it can be acted on.
|
|
397
|
+
export async function onMove(intake, activity, actor) {
|
|
398
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
399
|
+
if (!target) return 'Move without a target';
|
|
400
|
+
const contacts = intake.store.getContacts();
|
|
401
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
402
|
+
if (!rec) return; // not someone we follow
|
|
403
|
+
// Believed only if the actor we follow says so at its OWN origin: a Move is
|
|
404
|
+
// otherwise a redirect anyone could Append.
|
|
405
|
+
const doc = await intake.fetchAP(actor);
|
|
406
|
+
if (!doc) throw new Error(`cannot confirm ${actor} moved — will retry`);
|
|
407
|
+
const movedTo = typeof doc.movedTo === 'string' ? doc.movedTo : doc.movedTo?.id;
|
|
408
|
+
if (movedTo !== target) return `Move not corroborated by ${actor} (says ${movedTo || 'nothing'})`;
|
|
409
|
+
rec.movedTo = target;
|
|
410
|
+
intake.store.setContacts(contacts);
|
|
411
|
+
intake.store.addNotification({ type: 'move', actor, target });
|
|
412
|
+
intake.log(`${actor} moved to ${target} — follow the new account to keep seeing them`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function onAccept(intake, activity, actor, { trusted = false } = {}) {
|
|
416
|
+
const contacts = intake.store.getContacts();
|
|
417
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
418
|
+
// It has to answer the Follow we actually sent. followActor stores that
|
|
419
|
+
// activity for the later Undo, so the id is here to compare against; without
|
|
420
|
+
// the check any Accept from an actor we happen to follow flips the flag,
|
|
421
|
+
// including one answering a Follow we never made.
|
|
422
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
423
|
+
const ours = rec?.followActivity?.id;
|
|
424
|
+
// `named &&` used to be part of this, so an Accept naming NOTHING sailed
|
|
425
|
+
// past — which is the easy one to send, and it marks a request to a locked
|
|
426
|
+
// account as accepted when it is still sitting in their queue.
|
|
427
|
+
if (ours && named !== ours && !trusted) {
|
|
428
|
+
intake.log(`Accept from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (rec && !rec.accepted) {
|
|
432
|
+
rec.accepted = true;
|
|
433
|
+
intake.store.setContacts(contacts);
|
|
434
|
+
await intake.republish({ following: true, pending: true });
|
|
435
|
+
intake.log(`follow accepted by ${actor}`);
|
|
436
|
+
}
|
|
437
|
+
}
|