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,240 @@
|
|
|
1
|
+
// activity.mjs — what an inbound activity IS, before anything is done with
|
|
2
|
+
// it: the AS2 types the fediverse actually posts, the caps that bound what a
|
|
3
|
+
// stranger can make the agent hold, and the pure checks on ids, origins and
|
|
4
|
+
// authorship that every other intake module shares.
|
|
5
|
+
|
|
6
|
+
// A note's replies collection is rewritten WHOLE every time one is added, so
|
|
7
|
+
// without a cap the bytes are quadratic in a number a stranger chooses.
|
|
8
|
+
export const MAX_REPLIES_RECORDED = 500;
|
|
9
|
+
// Ids of activities already forwarded to our followers (§7.1.2), so a re-drain
|
|
10
|
+
// never re-broadcasts one. A ceiling on the record, not on forwarding.
|
|
11
|
+
export const MAX_FORWARDED = 2000;
|
|
12
|
+
// How many posts may wait for a group operator's decision. A ceiling, not a
|
|
13
|
+
// window: full refuses the newest rather than dropping the oldest.
|
|
14
|
+
export const MAX_PENDING_REVIEW = 500;
|
|
15
|
+
// A group's membership, cached: it changes when someone joins or leaves, and
|
|
16
|
+
// re-reading it on every arriving post would spend one stranger's fetch per
|
|
17
|
+
// message on a list that moves in days.
|
|
18
|
+
export const CO_MEMBER_TTL_MS = 24 * 60 * 60_000;
|
|
19
|
+
export const CO_MEMBER_MAX = 5000;
|
|
20
|
+
// An activity is a few kB. This is generous by two orders of magnitude and
|
|
21
|
+
// still bounds what one Append can make us hold in memory.
|
|
22
|
+
export const MAX_ITEM_BYTES = 512 * 1024;
|
|
23
|
+
// The AS2 actor types. A group is as much an actor as a person is.
|
|
24
|
+
export const ACTOR_TYPES = new Set(['Person', 'Group', 'Service', 'Application', 'Organization']);
|
|
25
|
+
// The AS2 types the fediverse actually posts. `Note` alone is Mastodon's world
|
|
26
|
+
// and not the fediverse's: an Article is a Plume or WriteFreely post, a
|
|
27
|
+
// Question is a poll, a Video is PeerTube, a Page is Lemmy, an Audio is
|
|
28
|
+
// Funkwhale. Insisting on Note dead-lettered every one of them as "not a
|
|
29
|
+
// verifiable Note" — from people the owner had chosen to follow, silently.
|
|
30
|
+
//
|
|
31
|
+
// They share the shape this code reads: attributedTo, content, published,
|
|
32
|
+
// inReplyTo, tag, attachment. A poll's options are dropped, which is a
|
|
33
|
+
// degraded rendering rather than a lost post.
|
|
34
|
+
export const CONTENT_TYPES = new Set(['Note', 'Article', 'Question', 'Page', 'Video', 'Audio', 'Image', 'Event']);
|
|
35
|
+
|
|
36
|
+
// A Question is a poll: its options live in oneOf (pick one) or anyOf (pick
|
|
37
|
+
// several), each carrying the tally its author's server maintains.
|
|
38
|
+
// Custom emojis ride the tag list; the images live at the author's server and
|
|
39
|
+
// the client fetches them from there.
|
|
40
|
+
// Content, name and summary were capped; nothing else was. One remote Question
|
|
41
|
+
// with thousands of options, or thousands of emoji, mention or attachment
|
|
42
|
+
// entries, put megabytes into a single statuses.json row — a document rewritten
|
|
43
|
+
// whole on every change. These are display lists: past a few dozen, nothing can
|
|
44
|
+
// render them and nobody meant them to be rendered.
|
|
45
|
+
// http(s) only. An emoji, attachment or mention URL is written straight into
|
|
46
|
+
// the client's markup, so `javascript:` and `data:` have no business in one.
|
|
47
|
+
// The store has guarded avatars this way all along (safeUrl, lib/store.mjs);
|
|
48
|
+
// these three lists were simply never put through it.
|
|
49
|
+
export const httpOnly = (u) => {
|
|
50
|
+
if (!u) return null;
|
|
51
|
+
try {
|
|
52
|
+
const p = new URL(String(u));
|
|
53
|
+
return (p.protocol === 'https:' || p.protocol === 'http:') ? String(u) : null;
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const MAX_MODQUEUE = 200;
|
|
58
|
+
export const MAX_EMOJIS = 60;
|
|
59
|
+
export const MAX_POLL_OPTIONS = 50;
|
|
60
|
+
export const MAX_OPTION_CHARS = 200;
|
|
61
|
+
export const MAX_MENTIONS = 60;
|
|
62
|
+
export const MAX_URL_CHARS = 2048;
|
|
63
|
+
|
|
64
|
+
export function emojisOf(note) {
|
|
65
|
+
return [].concat(note?.tag || [])
|
|
66
|
+
.filter(t => t?.type === 'Emoji' && t.icon?.url && t.name)
|
|
67
|
+
.slice(0, MAX_EMOJIS)
|
|
68
|
+
.map(t => ({
|
|
69
|
+
shortcode: String(t.name).replace(/^:|:$/g, '').slice(0, 64),
|
|
70
|
+
url: httpOnly(String(t.icon.url).slice(0, MAX_URL_CHARS)),
|
|
71
|
+
}))
|
|
72
|
+
.filter(e => e.url);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function pollOf(note) {
|
|
76
|
+
const opts = note?.oneOf || note?.anyOf;
|
|
77
|
+
if (!Array.isArray(opts) || !opts.length) return null;
|
|
78
|
+
return {
|
|
79
|
+
multiple: !!note.anyOf,
|
|
80
|
+
expiresAt: note.endTime || null,
|
|
81
|
+
closed: !!note.closed,
|
|
82
|
+
options: opts.slice(0, MAX_POLL_OPTIONS).map(o => ({
|
|
83
|
+
title: String(o?.name ?? '').slice(0, MAX_OPTION_CHARS),
|
|
84
|
+
votes: Number(o?.replies?.totalItems) || 0,
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// AS2 lets `type` be one string or a list, and implementations use both —
|
|
89
|
+
// `["Person","Service"]` is an ordinary actor. Read either form.
|
|
90
|
+
export const typesOf = (t) => (Array.isArray(t) ? t : [t]).filter(x => typeof x === 'string');
|
|
91
|
+
export const isContentType = (t) => typesOf(t).some(x => CONTENT_TYPES.has(x));
|
|
92
|
+
export const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
|
|
93
|
+
|
|
94
|
+
// What we will carry to our followers on someone else's behalf (§7.1.2): the
|
|
95
|
+
// activities a conversation is made of, and nothing else. A type this file
|
|
96
|
+
// does not handle falls out of handle() with no rejection, and "no rejection"
|
|
97
|
+
// is what qualifies an activity for forwarding — so without this gate a
|
|
98
|
+
// stranger could have anything at all, of a type nothing here reads,
|
|
99
|
+
// re-delivered to every follower over our signature.
|
|
100
|
+
export const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
|
|
101
|
+
// Of those, the ones we may re-deliver to our own followers over our own
|
|
102
|
+
// signature (§7.1.2). Deliberately narrower than FORWARDABLE: these three are
|
|
103
|
+
// the ones whose object this drain fetched from the author's origin and checked
|
|
104
|
+
// before accepting. A Like, an Announce or an Undo is taken on the envelope's
|
|
105
|
+
// word alone — relaying one is signing for a claim nothing corroborated.
|
|
106
|
+
export const FORWARD_TYPES = new Set(['Create', 'Update', 'Delete']);
|
|
107
|
+
// How many forwards one drain may send. A reply into a busy thread of ours is a
|
|
108
|
+
// handful; anything near this is a flood using us as an amplifier.
|
|
109
|
+
export const MAX_FORWARDS_PER_DRAIN = 20;
|
|
110
|
+
export const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
111
|
+
|
|
112
|
+
// What is worth keeping of an activity we are filing rather than acting on.
|
|
113
|
+
//
|
|
114
|
+
// A dead letter, a moderation entry and a waiting follow request each used to
|
|
115
|
+
// hold the WHOLE delivered activity — up to the 512 KB item cap — in documents
|
|
116
|
+
// that are serialized and PUT whole on every change. A stranger could inflate
|
|
117
|
+
// deadletter.json to ~100 MB and requests.json to ~250 MB and make us rewrite
|
|
118
|
+
// them on every batch.
|
|
119
|
+
//
|
|
120
|
+
// Everything the consumers need survives: `acceptActivity` wraps this as the
|
|
121
|
+
// Accept's object (and {id, type, actor, object} IS the standard Follow shape),
|
|
122
|
+
// `applyModeration` reads only `object`, and a dead letter is read by a human
|
|
123
|
+
// who wants to know what arrived, not to replay it.
|
|
124
|
+
export function trimActivity(a) {
|
|
125
|
+
if (!a || typeof a !== 'object') return a ?? null;
|
|
126
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id ?? null);
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const k of ['id', 'type', 'actor', 'target']) {
|
|
129
|
+
const v = idOf(a[k]);
|
|
130
|
+
if (v) out[k] = String(v).slice(0, 2048);
|
|
131
|
+
}
|
|
132
|
+
const obj = idOf(a.object);
|
|
133
|
+
if (obj) out.object = String(obj).slice(0, 2048);
|
|
134
|
+
// A typed object with no id still says what it was — a Block of nobody, an
|
|
135
|
+
// Undo of a Follow — and that is the whole of what the queue reads.
|
|
136
|
+
else if (a.object && typeof a.object === 'object' && a.object.type) {
|
|
137
|
+
out.object = { type: String(a.object.type).slice(0, 64) };
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Same origin AND, where the origin holds more than one identity, the same
|
|
143
|
+
// identity within it.
|
|
144
|
+
//
|
|
145
|
+
// Origin alone is the right test when an origin is one person — the ordinary
|
|
146
|
+
// fediverse server, and a subdomain pod. It is the WRONG test on a path-based
|
|
147
|
+
// host, and above all on a multi-tenant front: every tenant of fedipod.net has
|
|
148
|
+
// ids under `https://fedipod.net/u/<name>/`, so origin-equality made every
|
|
149
|
+
// tenant able to vouch for every other. One tenant could publish posts
|
|
150
|
+
// attributed to another, Update a neighbour's post, or Delete it — and a
|
|
151
|
+
// receiving Mastodon would believe it for the same reason we did.
|
|
152
|
+
//
|
|
153
|
+
// So when both ids carry an identity prefix, the prefixes must match too. The
|
|
154
|
+
// two shapes that exist here are the front's `/u/<name>/` and a pod's own AP
|
|
155
|
+
// root (`…/<root>/ap/…`); anything else has no prefix and falls back to origin,
|
|
156
|
+
// which is what a plain remote server should be judged by.
|
|
157
|
+
function identityPrefix(u) {
|
|
158
|
+
const m = /^(https?:\/\/[^/]+\/u\/[^/]+\/)/u.exec(u);
|
|
159
|
+
if (m) return m[1];
|
|
160
|
+
const ap = /^(https?:\/\/[^/]+\/(?:[^/]+\/)*?)ap\//u.exec(u);
|
|
161
|
+
return ap ? ap[1] : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sameIdentity(a, b) {
|
|
165
|
+
if (!sameOrigin(a, b)) return false;
|
|
166
|
+
const pa = identityPrefix(String(a));
|
|
167
|
+
const pb = identityPrefix(String(b));
|
|
168
|
+
if (!pa || !pb) return true; // no prefix to compare: origin is the answer
|
|
169
|
+
return pa === pb;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function sameOrigin(a, b) {
|
|
173
|
+
try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Is this socket URL the pod's own? The scheme has to be the socket form of the
|
|
177
|
+
// pod's — wss for https, ws for http — so a downgrade to plaintext from an https
|
|
178
|
+
// pod is somewhere else, not the same place unencrypted.
|
|
179
|
+
//
|
|
180
|
+
// The host may be the pod's, or a PARENT of it. Not a loosening for
|
|
181
|
+
// convenience: a CSS server that gives every pod a subdomain answers
|
|
182
|
+
// notifications from the server root, so jeff-zucker.teamid.live is served by
|
|
183
|
+
// wss://teamid.live/.notifications/… — which is the deployment this project
|
|
184
|
+
// actually runs on. Requiring an exact match dropped it to polling, and the
|
|
185
|
+
// live agents are how that was found rather than the suite.
|
|
186
|
+
//
|
|
187
|
+
// A sibling subdomain is still refused: only a suffix of our own host passes,
|
|
188
|
+
// and two labels minimum so `.live` cannot pose as everyone's parent. Not a
|
|
189
|
+
// public-suffix list — that is a dependency and a data file to keep current,
|
|
190
|
+
// and the party this guards against is the pod you already chose to trust.
|
|
191
|
+
//
|
|
192
|
+
// `localhost` is the one single-label parent allowed, because it is the one that
|
|
193
|
+
// cannot be anybody else: it is reserved to the loopback interface (RFC 6761),
|
|
194
|
+
// so `alice.localhost` and `localhost` are the same machine by definition and
|
|
195
|
+
// there is no stranger for the rule to keep out. Without this a pod served from
|
|
196
|
+
// a subdomain of localhost — which is how a Solid server with subdomain pods
|
|
197
|
+
// runs on a developer's machine — refused its own socket and fell back to
|
|
198
|
+
// polling, so every delivery waited up to two minutes.
|
|
199
|
+
export function sameSocketOrigin(socketUrl, podBase) {
|
|
200
|
+
let s, p;
|
|
201
|
+
try { s = new URL(socketUrl); p = new URL(podBase); } catch { return false; }
|
|
202
|
+
if (s.protocol !== (p.protocol === 'https:' ? 'wss:' : 'ws:')) return false;
|
|
203
|
+
if (s.host === p.host) return true;
|
|
204
|
+
const parent = s.hostname.toLowerCase();
|
|
205
|
+
return (parent.split('.').length >= 2 || parent === 'localhost')
|
|
206
|
+
&& s.port === p.port
|
|
207
|
+
&& p.hostname.toLowerCase().endsWith('.' + parent);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function httpUrl(u) {
|
|
211
|
+
try {
|
|
212
|
+
const p = new URL(String(u)).protocol;
|
|
213
|
+
return p === 'https:' || p === 'http:';
|
|
214
|
+
} catch { return false; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Who a note is BY. A document may only speak for an actor at its own origin.
|
|
218
|
+
// `attributedTo` used to be taken at face value, so a note served anywhere
|
|
219
|
+
// could name anyone: one at a host the attacker controls, claiming to be by
|
|
220
|
+
// someone the owner follows, passed every check we had — the envelope's
|
|
221
|
+
// sameOrigin compares the ACTIVITY to its object, never the object to its
|
|
222
|
+
// author — and landed in the home timeline and in the pod as them. For a group
|
|
223
|
+
// it went further still, because amplify() gates on the author's membership,
|
|
224
|
+
// so the group signed an Announce of it and delivered it to every member.
|
|
225
|
+
//
|
|
226
|
+
// `delivered` is the actor that brought it, used only when the note names no
|
|
227
|
+
// author of its own; it has to clear the same test, which is why a boost of an
|
|
228
|
+
// unattributed note is refused rather than credited to the booster.
|
|
229
|
+
//
|
|
230
|
+
// Returns the author, or null when nothing at the note's origin vouches for one.
|
|
231
|
+
export function authorOf(note, delivered = null) {
|
|
232
|
+
const claimed = [].concat(note?.attributedTo || [])
|
|
233
|
+
.map(a => (typeof a === 'string' ? a : a?.id)).find(Boolean) || null;
|
|
234
|
+
const author = claimed || delivered;
|
|
235
|
+
if (!author) return null;
|
|
236
|
+
// sameIdentity, not sameOrigin: on a multi-tenant front every tenant shares
|
|
237
|
+
// an origin, so origin-equality let any of them be credited with any other's
|
|
238
|
+
// post. See sameIdentity.
|
|
239
|
+
return sameIdentity(note?.id, author) ? author : null;
|
|
240
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// channel.mjs — the inbox's push channel: a WebSocketChannel2023 subscription
|
|
2
|
+
// on the pod's inbox container, reconnecting with backoff, and the drain it
|
|
3
|
+
// wakes. State (ws, wsState, reconnectTries, resubTimer) lives on the Intake
|
|
4
|
+
// so status pages and stop() see it; this module is the behaviour.
|
|
5
|
+
|
|
6
|
+
import * as podNotifications from '../../pod/notifications.mjs';
|
|
7
|
+
import { USER_AGENT } from '../../shared/ua.mjs';
|
|
8
|
+
import { HTTP_TIMEOUT_MS, readCapped } from '../../shared/safefetch.mjs';
|
|
9
|
+
import { sameSocketOrigin } from './activity.mjs';
|
|
10
|
+
|
|
11
|
+
// The channel a subscription returns outlives a dropped socket, so reconnecting
|
|
12
|
+
// reuses it. Creating a new one per reconnect is what buried solidcommunity.net
|
|
13
|
+
// in channel records they then had to sweep.
|
|
14
|
+
const CHANNEL_DOC = 'inbox-channel.json';
|
|
15
|
+
// A flapping socket used to POST a NEW WebSocketChannel2023 channel every two
|
|
16
|
+
// seconds — hundreds an hour against a server that is already struggling, and
|
|
17
|
+
// channel churn its operators have to sweep up. Backs off instead, and an open
|
|
18
|
+
// only triggers a sweep if we have not just swept.
|
|
19
|
+
const RECONNECT_MIN_MS = 2_000;
|
|
20
|
+
const RECONNECT_MAX_MS = 5 * 60_000;
|
|
21
|
+
// How long a socket must stay up before the backoff counts it as a success and
|
|
22
|
+
// resets. Shorter than that is a flap, not a connection.
|
|
23
|
+
const RECONNECT_STABLE_MS = 60_000;
|
|
24
|
+
const OPEN_DRAIN_MIN_GAP_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
// Jittered exponential, floor to ceiling, reset by a successful open.
|
|
27
|
+
export function reconnectDelay(intake) {
|
|
28
|
+
const capped = Math.min(RECONNECT_MIN_MS * 2 ** intake.reconnectTries, RECONNECT_MAX_MS);
|
|
29
|
+
intake.reconnectTries++;
|
|
30
|
+
return Math.round(capped * (0.8 + Math.random() * 0.4));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- push ---
|
|
34
|
+
// Any failure in here used to end push for the life of the process: the
|
|
35
|
+
// retry lived only in the "server refused the subscription" branch, so a
|
|
36
|
+
// network blip left wsState at never-connected and the agent silently on
|
|
37
|
+
// polling. Every path now schedules a retry on the same backoff.
|
|
38
|
+
export async function subscribe(intake) {
|
|
39
|
+
try {
|
|
40
|
+
await intake._subscribeOnce();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
intake.wsState = 'subscribe-error';
|
|
43
|
+
const wait = intake._reconnectDelay();
|
|
44
|
+
intake.log(`subscribe failed (${e.message}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
|
|
45
|
+
if (!intake.stopped) {
|
|
46
|
+
intake.resubTimer = setTimeout(() => intake.subscribe().catch(() => {}), wait);
|
|
47
|
+
intake.resubTimer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where this pod describes the services it offers. The pod says so on any
|
|
54
|
+
* response about one of its resources; the well-known path is only what a
|
|
55
|
+
* pod that says nothing has always used.
|
|
56
|
+
*/
|
|
57
|
+
export async function storageDescriptionUrl(intake) {
|
|
58
|
+
return podNotifications.storageDescriptionUrl(intake.urls.base,
|
|
59
|
+
{ headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function subscribeOnce(intake) {
|
|
63
|
+
// Reuse a channel we already have rather than asking for another one.
|
|
64
|
+
const saved = intake.store.read(CHANNEL_DOC, null);
|
|
65
|
+
if (saved?.receiveFrom && (!saved.endAt || Date.parse(saved.endAt) - Date.now() > 60_000)) {
|
|
66
|
+
intake._openSocket(saved.receiveFrom, true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const descUrl = await intake._storageDescriptionUrl();
|
|
70
|
+
const { channel, error } = await podNotifications.readWebSocketChannel(descUrl,
|
|
71
|
+
{ headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
|
|
72
|
+
if (!channel) { intake.wsState = 'unavailable'; intake.log(`${error} — polling only`); return; }
|
|
73
|
+
// The topic is a POD resource, and it travels in the BODY — so the url map
|
|
74
|
+
// RemotePod applies to the request line never reaches it. A fronted
|
|
75
|
+
// identity's inbox url names the front, which the pod cannot grant read on,
|
|
76
|
+
// and the subscription came back 403. A no-op when unfronted.
|
|
77
|
+
const topic = intake.urls.toPod ? intake.urls.toPod(intake.urls.inbox) : intake.urls.inbox;
|
|
78
|
+
const sub = await podNotifications.subscribeToInbox(intake.remote,
|
|
79
|
+
{ channelUrl: channel, podTopicUrl: topic });
|
|
80
|
+
const body = await readCapped(sub).then(JSON.parse).catch(() => null);
|
|
81
|
+
if (!body?.receiveFrom) {
|
|
82
|
+
intake.wsState = `subscribe-failed-${sub.status}`;
|
|
83
|
+
const wait = intake._reconnectDelay();
|
|
84
|
+
intake.log(`subscription failed (${sub.status}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
|
|
85
|
+
if (!intake.stopped) {
|
|
86
|
+
intake.resubTimer = setTimeout(() => intake.subscribe().catch(e => intake.log(`resubscribe: ${e.message}`)), wait);
|
|
87
|
+
intake.resubTimer.unref?.();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
intake.store.write(CHANNEL_DOC, { receiveFrom: body.receiveFrom, endAt: body.endAt || null });
|
|
92
|
+
intake._openSocket(body.receiveFrom, false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The socket URL arrives in the pod's own subscription response, and it was
|
|
96
|
+
// the one outbound address in the project that reached the network without
|
|
97
|
+
// passing anything — safefetch guards every fetch, and `new WebSocket()` is
|
|
98
|
+
// not a fetch. A pod that answered with somebody else's address had us open a
|
|
99
|
+
// long-lived connection there and treat what came back as our inbox waking up.
|
|
100
|
+
//
|
|
101
|
+
// Same origin as the pod, not assertPublicUrl: a pod on this machine is a
|
|
102
|
+
// documented setup and its socket is legitimately ws://localhost:3000, which
|
|
103
|
+
// a public-address check would refuse.
|
|
104
|
+
export function openSocket(intake, receiveFrom, reused) {
|
|
105
|
+
if (!sameSocketOrigin(receiveFrom, intake.urls.base)) {
|
|
106
|
+
intake.wsState = 'refused';
|
|
107
|
+
intake.log(`subscription named ${receiveFrom}, which is not this pod — polling only`);
|
|
108
|
+
if (reused) intake.store.write(CHANNEL_DOC, null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
intake.ws = new WebSocket(receiveFrom);
|
|
112
|
+
intake.ws.onopen = () => {
|
|
113
|
+
intake.wsState = 'open';
|
|
114
|
+
if (!intake._announcedPush) { intake.log('inbox push subscription active'); intake._announcedPush = true; }
|
|
115
|
+
intake._openedAt = Date.now();
|
|
116
|
+
// Anything that arrived while the socket was down is waiting — sweep it,
|
|
117
|
+
// unless a sweep just ran: a flapping socket must not re-list the inbox
|
|
118
|
+
// on every open.
|
|
119
|
+
if (Date.now() - intake.lastDrainAtMs > OPEN_DRAIN_MIN_GAP_MS) {
|
|
120
|
+
intake.drain().catch(e => intake.log(`drain: ${e.message}`));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
intake.ws.onmessage = () => intake.drain().catch(e => intake.log(`drain: ${e.message}`));
|
|
124
|
+
intake.ws.onclose = () => {
|
|
125
|
+
intake.wsState = 'closed';
|
|
126
|
+
// Only a connection that STAYED up counts as a success. Resetting on open
|
|
127
|
+
// alone meant the 2026-07-29 failure — a server that accepts the upgrade
|
|
128
|
+
// and then drops the socket on a crash cycle — reconnected at the 2s
|
|
129
|
+
// floor indefinitely: every cycle "succeeded", so the exponential cap was
|
|
130
|
+
// never reached, and each open also drained the inbox.
|
|
131
|
+
if (intake._openedAt && Date.now() - intake._openedAt >= RECONNECT_STABLE_MS) intake.reconnectTries = 0;
|
|
132
|
+
intake._openedAt = 0;
|
|
133
|
+
if (!intake.stopped) {
|
|
134
|
+
intake.resubTimer = setTimeout(() => intake.subscribe().catch(e => intake.log(`resubscribe: ${e.message}`)), intake._reconnectDelay());
|
|
135
|
+
intake.resubTimer.unref?.();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
intake.ws.onerror = () => {
|
|
139
|
+
intake.wsState = 'error';
|
|
140
|
+
// A channel we reused may simply be gone: forget it so the next attempt
|
|
141
|
+
// asks for a fresh one instead of retrying a dead URL forever.
|
|
142
|
+
if (reused) intake.store.write(CHANNEL_DOC, null);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// group.mjs — what a group actor does with what arrives (FEP-1b12): carries a
|
|
2
|
+
// member's post to the membership, knows who its co-members are, and queues a
|
|
3
|
+
// listed moderator's ask for the operator rather than running it.
|
|
4
|
+
|
|
5
|
+
import { trimActivity, MAX_MODQUEUE, MAX_PENDING_REVIEW, CO_MEMBER_TTL_MS, CO_MEMBER_MAX } from './activity.mjs';
|
|
6
|
+
|
|
7
|
+
// Which inbound activities count as a moderator's ask: a ban, an unban, a
|
|
8
|
+
// post removal, or a roster change naming OUR moderators collection. A
|
|
9
|
+
// moderator's ordinary traffic (their posts, likes, follows) is not
|
|
10
|
+
// moderation and takes the normal arms.
|
|
11
|
+
export function isModerationAsk(intake, activity) {
|
|
12
|
+
if (activity.type === 'Block') return true;
|
|
13
|
+
if (activity.type === 'Undo') {
|
|
14
|
+
return typeof activity.object === 'object' && activity.object?.type === 'Block';
|
|
15
|
+
}
|
|
16
|
+
if (activity.type === 'Delete') {
|
|
17
|
+
const id = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
18
|
+
const s = id && intake.store.getStatuses().find(x => x.noteId === id);
|
|
19
|
+
// Only a post the group holds and did not author — removing those is
|
|
20
|
+
// moderation; everything else is the author's own Delete.
|
|
21
|
+
return !!s && s.kind !== 'post';
|
|
22
|
+
}
|
|
23
|
+
if (activity.type === 'Add' || activity.type === 'Remove') {
|
|
24
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
25
|
+
return target === intake.urls.moderators;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Held, not run: one entry per distinct ask, capped, waiting for the
|
|
31
|
+
// operator to apply or dismiss it (social.applyModeration).
|
|
32
|
+
// A moderator's WORD, not their proof. `actor` is a field in an unsigned
|
|
33
|
+
// body and a moderator's URL is public, so anyone can claim to be one — which
|
|
34
|
+
// is exactly why these are QUEUED for the operator rather than run. What was
|
|
35
|
+
// missing is that the queue did not say which is which, and a stranger could
|
|
36
|
+
// fill all 200 slots and push the real asks out.
|
|
37
|
+
//
|
|
38
|
+
// So: the entry records whether the door vouched for the sender, and when the
|
|
39
|
+
// queue is full the UNVERIFIED entries are what get dropped. A real
|
|
40
|
+
// moderator's ask cannot be crowded out by someone impersonating them.
|
|
41
|
+
export function queueModeration(intake, activity, actor, { trusted = false } = {}) {
|
|
42
|
+
const q = intake.store.read('modqueue.json', []);
|
|
43
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
44
|
+
const key = [activity.type, actor, objectId || JSON.stringify(activity.object || null)].join(' ');
|
|
45
|
+
const seen = q.find(e => e.key === key);
|
|
46
|
+
if (seen) {
|
|
47
|
+
// The same ask arriving verified is worth more than the copy we hold.
|
|
48
|
+
if (trusted && !seen.verified) {
|
|
49
|
+
seen.verified = true;
|
|
50
|
+
intake.store.write('modqueue.json', q);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
q.unshift({
|
|
55
|
+
key, id: (intake.serial++).toString(36) + '-' + q.length,
|
|
56
|
+
type: activity.type, moderator: actor, activity: trimActivity(activity),
|
|
57
|
+
verified: !!trusted, at: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
let kept = q;
|
|
60
|
+
if (kept.length > MAX_MODQUEUE) {
|
|
61
|
+
const verified = kept.filter(e => e.verified);
|
|
62
|
+
const rest = kept.filter(e => !e.verified);
|
|
63
|
+
// Verified first, then the newest unverified up to the cap.
|
|
64
|
+
kept = [...verified, ...rest].slice(0, MAX_MODQUEUE);
|
|
65
|
+
}
|
|
66
|
+
intake.store.write('modqueue.json', kept);
|
|
67
|
+
intake.log(`moderation queued from ${actor}${trusted ? '' : ' (unverified)'}: ${activity.type} ${objectId || ''}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Anyone can Append to a public inbox, so arriving is not the same as being
|
|
71
|
+
// carried to every follower. Membership is the gate: you cannot post to a
|
|
72
|
+
// group you have not joined, and declining to carry a member is the only
|
|
73
|
+
// moderation a group can actually enforce.
|
|
74
|
+
export async function amplify(intake, noteId, { approved = false, activity = null } = {}) {
|
|
75
|
+
const s = intake.store.getStatuses().find(x => x.noteId === noteId);
|
|
76
|
+
if (!s) return;
|
|
77
|
+
if (s.announcedAt) return; // a re-delivered Create announces once
|
|
78
|
+
// A DM to the group, or a followers-only post it happened to receive, was
|
|
79
|
+
// addressed to less than the world — carrying it would widen the author's
|
|
80
|
+
// audience for them. A group only ever amplifies public posts.
|
|
81
|
+
if (s.direct || s.nonPublic) {
|
|
82
|
+
intake.log(`not amplified — ${noteId} was not addressed publicly, and a group never widens a post's audience`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const contacts = intake.store.getContacts();
|
|
86
|
+
if (!contacts.followers.some(f => f.actor === s.actor)) {
|
|
87
|
+
intake.log(`not amplified — ${s.actor} is not a member`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (intake.store.getMuted().actors.includes(s.actor)) {
|
|
91
|
+
intake.log(`not amplified — ${s.actor} is muted`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// A reviewed group carries nothing until its operator says so.
|
|
95
|
+
if (intake.config.review && !approved) {
|
|
96
|
+
const pending = intake.store.getPending();
|
|
97
|
+
if (!pending.some(p => p.noteId === noteId)) {
|
|
98
|
+
// Full means refuse the new one, not evict the oldest. `slice(0, 500)`
|
|
99
|
+
// dropped from the tail, so one member posting 500 notes silently
|
|
100
|
+
// discarded everything the operator was still deciding about — the
|
|
101
|
+
// posts were never carried, never refused, and left no record that they
|
|
102
|
+
// had ever arrived. Becoming a member costs one Follow when joins are
|
|
103
|
+
// unmoderated, which is the default.
|
|
104
|
+
//
|
|
105
|
+
// Not carrying it is what a reviewed group does with anything it has
|
|
106
|
+
// not approved, so refusing is the same outcome the queue was for.
|
|
107
|
+
if (pending.length >= MAX_PENDING_REVIEW) {
|
|
108
|
+
intake.log(`review queue is full (${MAX_PENDING_REVIEW}) — ${noteId} not held. `
|
|
109
|
+
+ 'Approve or decline what is waiting and it will be carried on redelivery.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// The activity rides along: approving later still has to wrap the one
|
|
113
|
+
// the member actually sent, not a reconstruction of it.
|
|
114
|
+
pending.unshift({ noteId, actor: s.actor, activity, at: new Date().toISOString() });
|
|
115
|
+
intake.store.setPending(pending);
|
|
116
|
+
}
|
|
117
|
+
intake.log(`held for review: ${noteId}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// A member's Bluesky post: the carry is a native repost by the group's
|
|
121
|
+
// account. It reaches AP followers only through the author's own bridge —
|
|
122
|
+
// the group never fabricates an AP object for someone else's words.
|
|
123
|
+
if (s.kind === 'bsky') {
|
|
124
|
+
if (!intake.bskyGroup) { intake.log(`not amplified — ${noteId} is a bluesky post and no account is connected`); return; }
|
|
125
|
+
return intake.bskyGroup.carry(s);
|
|
126
|
+
}
|
|
127
|
+
const held = intake.store.getPending().find(p => p.noteId === noteId);
|
|
128
|
+
const inboxes = intake.announceTargets(s.actor);
|
|
129
|
+
const { announceActivity } = await import('../wire.mjs');
|
|
130
|
+
// Wrap the member's own activity when we have it; a bare note URL is the
|
|
131
|
+
// fallback, and renders as a plain boost rather than a group carry. The
|
|
132
|
+
// group names itself as the audience (FEP-1b12).
|
|
133
|
+
//
|
|
134
|
+
// `activity` is the envelope as DELIVERED — a document the sender wrote,
|
|
135
|
+
// which the group would otherwise re-sign and hand to every follower with
|
|
136
|
+
// whatever addressing, tags and object body it carried. Only the note id was
|
|
137
|
+
// ever verified (ingestNote fetched it from the author's origin and checked
|
|
138
|
+
// the attribution), so only the note id is safe to pass on: send the bare
|
|
139
|
+
// id unless the wrapper's own object id agrees with what we verified.
|
|
140
|
+
const wrapperObject = (a) => {
|
|
141
|
+
const inner = a?.object;
|
|
142
|
+
const id = typeof inner === 'string' ? inner : inner?.id;
|
|
143
|
+
return id === noteId ? a : null;
|
|
144
|
+
};
|
|
145
|
+
const act = announceActivity({
|
|
146
|
+
urls: intake.urls,
|
|
147
|
+
object: wrapperObject(activity) || wrapperObject(held?.activity) || noteId,
|
|
148
|
+
serial: intake.serial++,
|
|
149
|
+
audience: intake.urls.actor,
|
|
150
|
+
});
|
|
151
|
+
await intake.deliverer.deliverToAll(inboxes, act);
|
|
152
|
+
// Marked carried before recorded: a failed outbox write costs one missing
|
|
153
|
+
// entry, a failed status write would carry the same post twice.
|
|
154
|
+
intake.store.updateStatus(noteId, { announcedAt: new Date().toISOString(), announceActivity: act });
|
|
155
|
+
await intake.publisher.recordOutbox(act);
|
|
156
|
+
intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
|
|
157
|
+
intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
158
|
+
// The same carry, shown natively to the group's Bluesky followers.
|
|
159
|
+
await intake.bskyGroup?.mirrorCarry(s)
|
|
160
|
+
.catch(e => intake.log(`bluesky mirror of the carry failed: ${e.message}`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Is this actor in a group we are in? Each followed Group's membership is a
|
|
164
|
+
// public collection, read at most once a day and cached — a membership list
|
|
165
|
+
// is slow-moving, and this runs on arriving mail.
|
|
166
|
+
export async function isCoMember(intake, actor) {
|
|
167
|
+
if (intake.config.kind === 'group') return false; // a group has members, not peers
|
|
168
|
+
const groups = intake.store.getContacts().following
|
|
169
|
+
.filter(f => f.accepted && intake.store.getActors()[f.actor]?.type === 'Group')
|
|
170
|
+
.map(f => f.actor);
|
|
171
|
+
if (!groups.length) return false;
|
|
172
|
+
const cache = intake.store.read('comembers.json', {});
|
|
173
|
+
const fresh = Date.now() - CO_MEMBER_TTL_MS;
|
|
174
|
+
let changed = false;
|
|
175
|
+
for (const g of groups) {
|
|
176
|
+
const held = cache[g];
|
|
177
|
+
if (held && Date.parse(held.at || 0) > fresh) continue;
|
|
178
|
+
const doc = await intake.fetchAP(g).catch(() => null);
|
|
179
|
+
const list = doc?.followers ? await intake.collectionMembers(doc.followers) : null;
|
|
180
|
+
// A list we could not read keeps whatever we had: losing it would demote
|
|
181
|
+
// every co-member to a stranger for a day because one fetch failed.
|
|
182
|
+
if (!list) continue;
|
|
183
|
+
cache[g] = { at: new Date().toISOString(), members: list };
|
|
184
|
+
changed = true;
|
|
185
|
+
}
|
|
186
|
+
if (changed) intake.store.write('comembers.json', cache);
|
|
187
|
+
return groups.some(g => cache[g]?.members?.includes(actor));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The actor ids in a (possibly paged) public collection, capped.
|
|
191
|
+
export async function collectionMembers(intake, url) {
|
|
192
|
+
const out = [];
|
|
193
|
+
let next = url;
|
|
194
|
+
for (let page = 0; next && page < 10 && out.length < CO_MEMBER_MAX; page++) {
|
|
195
|
+
const doc = await intake.fetchAP(next).catch(() => null);
|
|
196
|
+
if (!doc) return out.length ? out : null;
|
|
197
|
+
for (const item of doc.orderedItems || doc.items || []) {
|
|
198
|
+
if (typeof item === 'string') out.push(item);
|
|
199
|
+
}
|
|
200
|
+
next = doc.first && page === 0 ? doc.first : doc.next;
|
|
201
|
+
if (typeof next === 'object') next = next?.id;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Who an Announce for `author` goes to. Shared with the retract path: an Undo
|
|
207
|
+
// that reached a different set than the Announce did would leave the post
|
|
208
|
+
// standing for whoever the two sets disagreed about.
|
|
209
|
+
// The author's own target is dropped only when it serves nobody else — a
|
|
210
|
+
// shared inbox carries the whole server's members.
|
|
211
|
+
export function announceTargets(intake, author) {
|
|
212
|
+
const byTarget = new Map();
|
|
213
|
+
for (const f of intake.store.getContacts().followers) {
|
|
214
|
+
const t = f.sharedInbox || f.inbox;
|
|
215
|
+
if (!t) continue;
|
|
216
|
+
if (!byTarget.has(t)) byTarget.set(t, new Set());
|
|
217
|
+
byTarget.get(t).add(f.actor);
|
|
218
|
+
}
|
|
219
|
+
return [...byTarget]
|
|
220
|
+
.filter(([, who]) => !(who.size === 1 && who.has(author)))
|
|
221
|
+
.map(([t]) => t);
|
|
222
|
+
}
|