fedipod 1.44.0 → 1.47.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.
Files changed (42) hide show
  1. package/README.md +8 -13
  2. package/architecture.md +5 -2
  3. package/gateway.md +56 -24
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +23 -74
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +12 -1
  11. package/lib/core/intake/received.mjs +78 -0
  12. package/lib/core/publisher/notes.mjs +4 -1
  13. package/lib/core/social.mjs +2 -0
  14. package/lib/core/storage.mjs +54 -5
  15. package/lib/core/wire.mjs +16 -5
  16. package/lib/gateway/account-agent.mjs +20 -4
  17. package/lib/gateway/copy.mjs +95 -5
  18. package/lib/gateway/front-core.mjs +18 -9
  19. package/lib/gateway/gateway-core.mjs +46 -14
  20. package/lib/gateway/held-mail.mjs +11 -4
  21. package/lib/gateway/httpsig.mjs +6 -1
  22. package/lib/gateway/keeper.mjs +9 -6
  23. package/lib/gateway/masto-gateway.mjs +140 -40
  24. package/lib/gateway/private-read.mjs +98 -0
  25. package/lib/gateway/quiet.mjs +4 -3
  26. package/lib/gateway/state-api.mjs +73 -18
  27. package/lib/pod/notes.mjs +25 -0
  28. package/lib/pod/urls.mjs +3 -0
  29. package/lib/server/embed.mjs +25 -0
  30. package/lib/surface/routes/owner.mjs +2 -2
  31. package/package.json +2 -2
  32. package/web/admin/gateway.js +8 -24
  33. package/web/admin/index.html +0 -16
  34. package/web/admin/record.js +1 -2
  35. package/web/admin/upkeep.js +1 -5
  36. package/web/app/admin-facade.mjs +1 -1
  37. package/web/app/agent.mjs +27 -10
  38. package/web/app/copy-mode.mjs +98 -13
  39. package/web/app/dist/boot.js +12 -3
  40. package/web/app/dist/boot.js.map +2 -2
  41. package/web/app/dist/sw.js +582 -282
  42. package/web/app/dist/sw.js.map +4 -4
package/README.md CHANGED
@@ -5,8 +5,9 @@
5
5
  FediPod gives you a Fediverse account whose data lives on a Solid pod. You
6
6
  follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in
7
7
  one timeline. Your posts, followers and settings are kept on your pod. While fedipod.net
8
- keeps your account running, it works from a copy of them and writes it to your
9
- pod every fifteen minutes; your signing key never leaves your pod.
8
+ keeps your account running, it works from a copy of them, and every fifteen
9
+ minutes it writes that copy to your pod and deletes it; your signing key never
10
+ leaves your pod.
10
11
 
11
12
  The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. Sign-up points you to a pod provider if you need a pod, then attaches a Fediverse identity to the pod you sign in with.
12
13
 
@@ -109,14 +110,6 @@ there it takes over; the first drops back to reading.
109
110
 
110
111
  ## What the browser version does not do
111
112
 
112
- - **Scheduled posts, if you stop fedipod.net keeping your account running.**
113
- It does by default, and publishes them while FediPod is closed. Turned off
114
- on the manage page, nothing runs between now and the time you picked, so
115
- the composer refuses one rather than dropping it later.
116
- - **Notifications, and other clients, if you stop fedipod.net keeping your
117
- account running.** While it does (the default), any Mastodon app, phone or
118
- desktop, can use your account with `fedipod.net` as its server, and an app
119
- that offers notifications gets them on your phone with everything closed.
120
113
  - **Live updates.** The client refreshes by polling.
121
114
  - **Hosting a group.** Joining one works.
122
115
 
@@ -128,9 +121,11 @@ Everything you publish and everything you read is stored on your pod. Your
128
121
  signing key is stored there too, in a container only you can read through
129
122
  your pod's login.
130
123
  While fedipod.net keeps your account running, its own pod identity can read
131
- that container too, so it can act for you while FediPod is closed, and it
132
- works from a copy of your account's data that it writes back to your pod every
133
- fifteen minutes. Your key stays on your pod only.
124
+ that container too, so it can act for you while FediPod is closed. It works
125
+ from a copy of your account's data, and every fifteen minutes it writes that
126
+ copy to your pod and deletes it, so none of your data stays at fedipod.net
127
+ longer than that. The next time anything needs your account, the copy is made
128
+ again from your pod. Your key stays on your pod only.
134
129
  It all lives in the `fedipod` container you chose at sign-up. Your pod's
135
130
  public type index records it, as an ActivityStreams actor, which is how
136
131
  FediPod and other Solid apps find your account again.
package/architecture.md CHANGED
@@ -21,8 +21,11 @@ account's agent whenever FediPod is closed, under a pod identity of its own
21
21
  that the owner's rules name, reading the signing key from the pod when it
22
22
  needs it. The account's state documents live in a working copy at the
23
23
  gateway, which FediPod in the browser, the gateway and any Mastodon app all
24
- work from, and which the gateway writes to the pod every fifteen minutes. See
25
- [the gateway](gateway.md).
24
+ work from. Every fifteen minutes the gateway writes the copy to the pod and
25
+ deletes it, and makes it again from the pod when something next needs it (the
26
+ hold). A gateway's admin may turn the hold off: then there is no copy, and
27
+ everything works on the pod, the browser and the gateway taking turns by the
28
+ pod's lease. See [the gateway](gateway.md).
26
29
 
27
30
  [FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
28
31
  Community Solid Server, so anyone with a pod on that server can opt in to a
package/gateway.md CHANGED
@@ -15,7 +15,10 @@ yours, which your agent then signs and sends. It still holds no key, but what yo
15
15
  trust it with grows by that much. A browser account is also kept running by
16
16
  the gateway (below). That gives the gateway your key while it works, so it can
17
17
  post as you, as any Fediverse server can, and your account's working data is
18
- kept at the gateway too and written to your pod every fifteen minutes.
18
+ held at the gateway while it works, for at most fifteen minutes at a time. For
19
+ a kept account the gateway also answers a follower's server asking for one
20
+ of your followers-only posts, and the named person's server asking for a
21
+ direct one, when that server signs its request; nobody else gets them.
19
22
 
20
23
  A FediPod install works without any gateway at all. Deliveries go straight to
21
24
  your pod inbox, which holds them whether your agent is running or not.
@@ -73,8 +76,9 @@ mode says how far you trust the door, and every step is reversible:
73
76
  - **locked** — your inbox accepts writes only from the gateway.
74
77
 
75
78
  In every mode past **off**, the door itself is already filtering: blocked
76
- actors, content that does not concern you, and forged signatures are dropped
77
- at the door and never reach the pod. The mode says only how far the agent
79
+ actors and content that does not concern you are dropped at the door and
80
+ never reach the pod; a delivery whose signature does not verify is passed on
81
+ marked unverified, and the agent decides by fetching what it claims to be. The mode says only how far the agent
78
82
  believes the door's receipts.
79
83
 
80
84
  The shadow numbers come from the agent:
@@ -170,28 +174,57 @@ closed, fedipod.net does two things for it.
170
174
  your pod one by one, and reach your pod in batches of up to a hundred: when you
171
175
  open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
172
176
  goes straight to your pod as before. A DeviceAgent's mail always goes straight
173
- to its pod.
177
+ to its pod. On a gateway whose admin has turned the hold off (below), mail
178
+ always goes straight to your pod.
174
179
 
175
- **The gateway keeps your account running.** Unless you turn it off, the
176
- gateway accepts your follows, tries again what failed to go out, and publishes
177
- the posts you scheduled, all while FediPod is closed. Other mail waits on your
180
+ **The gateway keeps your account running.** It accepts your follows, tries
181
+ again what failed to go out, and publishes the posts you scheduled, all while FediPod is closed. Other mail waits on your
178
182
  pod for FediPod to open, as before. It works under its own pod identity, which your app
179
183
  names in the access rules on your FediPod folder. It reads your signing key
180
184
  from your pod when it needs it, so it can post as you, and keeps no copy of the
181
- key. **Stop keeping it running** on the manage page takes it out of the rules.
182
- If fedipod.net ever changes its own pod identity, your FediPod moves your
185
+ key. If fedipod.net ever changes its own pod identity, your FediPod moves your
183
186
  account over the next time you open it, with nothing for you to do.
184
187
  Scheduling a post is offered only while this is on, because otherwise nothing
185
188
  would be running when the time came.
186
189
 
187
- **Your account's working data is kept at the gateway.** While the gateway
188
- keeps your account running, your timeline, notifications, followers, settings
189
- and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
190
- browser, the gateway and any app you use all work from that one copy, so they
191
- never disagree. fedipod.net writes what changed to your pod every fifteen
192
- minutes, and everything at once when you stop it keeping your account running,
193
- move your address or close it. Your signing key and the passwords of accounts
194
- you connected elsewhere are never in the copy; they stay on your pod only.
190
+ **Your account's working data is held at the gateway for at most fifteen
191
+ minutes.** While the gateway keeps your account running, FediPod in your
192
+ browser, the gateway and any app you use all work from one copy of your
193
+ timeline, notifications, followers, settings and the rest of your FediPod
194
+ state, made at fedipod.net from your pod, so they never disagree. Every
195
+ fifteen minutes, whatever is happening, fedipod.net writes that copy to your
196
+ pod and deletes it. The next time anything needs your account (FediPod, an
197
+ app, or the gateway's own work while you're away), the copy is made again from
198
+ your pod. You don't notice, except that an app's first check after a deletion
199
+ can take a few seconds longer. When your address moves or is closed, the copy
200
+ goes to your pod at once. Your signing key and the passwords of accounts you
201
+ connected elsewhere are never in the copy; they stay on your pod only.
202
+
203
+ ## The hold, for a gateway's admin
204
+
205
+ Whoever runs a gateway decides whether it holds copies of accounts at all,
206
+ with one setting, `FEDIPOD_HOLD` ([netlify/README.md](netlify/README.md)).
207
+
208
+ - **On**, the default and fedipod.net's choice: as above. Mail waits at the
209
+ gateway for up to fifteen minutes, and each account's copy is written to its
210
+ pod and deleted every fifteen minutes.
211
+ - **Off**: the gateway keeps no copy of anyone's account and holds no mail.
212
+ Mail goes straight to the pod as it arrives, an app's every check reads the
213
+ pod, and FediPod in the browser works on the pod, taking turns with the
214
+ gateway when an app acts. None of anyone's mail, timeline or direct messages
215
+ is ever stored at the gateway. It costs more: roughly two to four times the
216
+ gateway's hosting per account, and about ten times the requests on each pod.
217
+ Apps are slower, and on a pod provider that limits traffic they can show
218
+ errors at busy times.
219
+
220
+ A change takes effect within fifteen minutes. Off to on: an account's copy is
221
+ made the next time anything uses it. On to off: every copy is written to its
222
+ pod and deleted, at the next round, or by FediPod itself within five minutes
223
+ where it is open, so nothing it is writing is lost.
224
+
225
+ Either way the gateway keeps app sign-ins (as fingerprints), phone-notification
226
+ sign-ups, each account's record (with its door secret), the time an account's
227
+ next scheduled post is due, and public copies of posts for up to an hour.
195
228
 
196
229
  ## Using any Mastodon app
197
230
 
@@ -200,7 +233,8 @@ elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
200
233
  your server. The app sends you to a fedipod.net page that asks for your
201
234
  Fediverse address or your WebID and signs you in at your own pod; no password
202
235
  is typed on fedipod.net. The app then reads your timeline and notifications from your
203
- account's copy at the gateway, and posts, boosts, likes and follows as you.
236
+ account's copy at the gateway (from your pod, on a gateway with the hold off),
237
+ and posts, boosts, likes and follows as you.
204
238
 
205
239
  An app checks for new posts every minute or so while it is open; there are no
206
240
  live updates. Accounts are made on the fedipod.net front page, with a pod, not
@@ -222,13 +256,11 @@ about each browser account — when its owner last signed in or posted, and
222
256
  how much content has arrived since — and acts on them.
223
257
 
224
258
  **Paused.** After about 5,000 posts, replies, likes, boosts and edits since
225
- you were last here, or when you say so on the manage page, the door accepts
226
- content and discards it. Follows, unfollows, account moves, deletions and
227
- blocks still reach your pod. Signing in ends an automatic pause by itself; a
228
- pause you set lasts until you lift it.
259
+ you were last here, the door accepts content and discards it. Follows,
260
+ unfollows, account moves, deletions and blocks still reach your pod. Signing
261
+ in ends the pause.
229
262
 
230
- **Closed.** After six months without a sign-in, or when you say so on the
231
- manage page, the address is closed for good: its handle, its actor and its
263
+ **Closed.** After six months without a sign-in, the address is closed for good: its handle, its actor and its
232
264
  door answer 410 Gone, other servers drop the account the next time they look,
233
265
  and nobody can take the name. Nothing on your pod is touched. An address that
234
266
  had already moved to another gateway keeps answering as moved.
package/gui.md CHANGED
@@ -97,8 +97,9 @@ is marked **sign in again** rather than quietly dropped.
97
97
 
98
98
  ## Handling follow requests
99
99
 
100
- New followers appear under **Follow requests** with **Accept** and **Refuse**
101
- beside them; nothing is accepted without you.
100
+ New followers are accepted automatically. Set **new followers** to *wait for
101
+ approval* and they appear under **Follow requests** instead, with **Accept**
102
+ and **Refuse** beside them; nothing is accepted without you.
102
103
  They show in any Mastodon client as well, now that the client API serves the
103
104
  queue and takes both answers.
104
105
  Groups are different — joining
@@ -32,11 +32,6 @@ import * as podNotes from '../pod/notes.mjs';
32
32
 
33
33
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
34
34
 
35
- // How many archived items one page of the inbox will read. A page is one
36
- // month, and a month with more than this is served short rather than costing
37
- // the pod an unbounded read; the log says when that happened.
38
- const MAX_INBOX_PAGE = 500;
39
-
40
35
  // How many recorded activities one page of the locally-served outbox carries.
41
36
  const MAX_OUTBOX_PAGE = 50;
42
37
 
@@ -99,82 +94,35 @@ export class C2S {
99
94
  return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
100
95
  }
101
96
 
102
- /** The months the archive holds, newest first. One container listing. */
103
- async archiveMonths() {
104
- const archive = this.agent.intake?.archive;
105
- if (!archive) return [];
106
- const { names } = await archive.list('');
107
- return (names || [])
108
- .map((n) => n.replace(/\/$/u, ''))
109
- .filter((n) => /^\d{4}-\d{2}$/u.test(n))
110
- .sort()
111
- .reverse();
112
- }
113
-
114
97
  /**
115
- * The owner's own inbox, §5.2. Paged by month because that is how the
116
- * archive is stored, so a page costs one listing and a read per item and no
117
- * page is dearer for another month being large.
98
+ * The owner's own inbox, §5.2: the collection the drain keeps on the pod in
99
+ * the private container (intake/received.mjs), served under this agent's
100
+ * address so a client holding no pod credential of its own can read it.
101
+ * Nothing is built per request; a page is the pod's page.
118
102
  */
119
- async sendInbox(res, url) {
120
- const id = `${this.urls.base}ap/inbox`;
103
+ async sendInbox(res, url, origin) {
104
+ const id = `${origin}ap/inbox`; // named for the client that asked, as the outbox is
105
+ const own = this.urls.ownInbox;
121
106
  const page = url?.searchParams?.get('page') || null;
122
- let months;
107
+ if (page !== null && !/^[1-9]\d{0,6}$/u.test(page)) return this.send(res, 400, { error: 'page is a number' });
108
+ let doc;
123
109
  try {
124
- months = await this.archiveMonths();
110
+ doc = await this.agent.remote.getJson(page ? `${own}-${page}` : own);
125
111
  } catch (e) {
126
- return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
112
+ return this.send(res, 502, { error: `the inbox collection could not be read: ${e.message}` });
127
113
  }
128
-
129
- if (!page) {
130
- if (!months.length && this.store.getConfig()?.archiveInbox === false) {
114
+ if (!doc) {
115
+ if (page) return this.send(res, 404, { error: 'no such page' });
116
+ if (this.store.getConfig()?.archiveInbox === false) {
131
117
  this.log('inbox read: nothing to show — this identity does not keep what it receives');
132
118
  }
133
- return this.send(res, 200, {
134
- '@context': wire.AS_CTX, id, type: 'OrderedCollection',
135
- ...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
136
- });
137
- }
138
- if (!/^\d{4}-\d{2}$/u.test(page)) {
139
- return this.send(res, 400, { error: 'page names a month, written 2026-09' });
140
- }
141
-
142
- const archive = this.agent.intake?.archive;
143
- let names = [];
144
- try {
145
- // The trailing slash matters: without it this names a document, not the
146
- // container, and a pod answers about the wrong thing.
147
- ({ names } = await archive.list(`${page}/`));
148
- } catch (e) {
149
- return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
150
- }
151
- const files = (names || []).filter((n) => n.endsWith('.json')).sort();
152
- if (files.length > MAX_INBOX_PAGE) {
153
- this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
119
+ return this.send(res, 200, { '@context': wire.AS_CTX, id, type: 'OrderedCollection', totalItems: 0, orderedItems: [] });
154
120
  }
155
- const kept = [];
156
- for (const file of files.slice(0, MAX_INBOX_PAGE)) {
157
- // Read as written: these records are JSON-LD, and the default read asks
158
- // turtle-first, which a server is free to answer with turtle.
159
- const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
160
- if (!read?.ok || !read.body) continue;
161
- try {
162
- const record = JSON.parse(read.body);
163
- // The record wraps the bytes as they arrived; the activity is those
164
- // bytes, not a retelling of them.
165
- kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
166
- } catch { /* a record that will not parse is not one that can be served */ }
167
- }
168
- kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
169
- const older = months.filter((m) => m < page)[0] || null;
170
- return this.send(res, 200, {
171
- '@context': wire.AS_CTX,
172
- id: `${id}?page=${page}`,
173
- type: 'OrderedCollectionPage',
174
- partOf: id,
175
- ...(older ? { next: `${id}?page=${older}` } : {}),
176
- orderedItems: kept.map((k) => k.activity),
177
- });
121
+ // The pod's names become this agent's: the head is `id`, page n is `id?page=n`.
122
+ const here = (v) => (typeof v === 'string' && v.startsWith(own)
123
+ ? (v === own ? id : v.replace(`${own}-`, `${id}?page=`)) : v);
124
+ for (const k of ['id', 'partOf', 'first', 'last', 'next', 'prev']) if (k in doc) doc[k] = here(doc[k]);
125
+ return this.send(res, 200, doc);
178
126
  }
179
127
 
180
128
  // The actor a client-to-server client on this machine reads. The same
@@ -277,7 +225,7 @@ export class C2S {
277
225
  }
278
226
  const reader = await this.auth(req, pathname);
279
227
  if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
280
- return this.sendInbox(res, url);
228
+ return this.sendInbox(res, url, this.localOrigin(req) || this.urls.base);
281
229
  }
282
230
  if (req.method === 'GET' || req.method === 'HEAD') {
283
231
  // The owner, signed in, reads every message the actor produced (§5.1:
@@ -312,7 +260,8 @@ export class C2S {
312
260
  // A viewer (another agent holds the lease) may not act — but the user
313
261
  // acting HERE outranks an idle active agent elsewhere, exactly as on the
314
262
  // facade: a write attempt claims the lease, and only a failed claim 503s.
315
- if (this.agent.viewer) {
263
+ // So does an agent whose state its gateway acts on too (sharesState).
264
+ if (this.agent.viewer || this.agent.sharesState) {
316
265
  const took = await this.agent.requestTakeover?.();
317
266
  if (!took) return this.send(res, 503, { error: 'another agent is active for this pod — takeover failed, try again' });
318
267
  }
@@ -149,10 +149,11 @@ export class MastoApi {
149
149
  // A viewer-mode agent (another agent holds the drain lease) may not act —
150
150
  // but a user acting HERE outranks the idle active agent elsewhere, so a
151
151
  // write attempt claims the lease and proceeds. Only a failed claim 503s.
152
- // An agent working from a copy its gateway shares (the browser build,
153
- // web/app/copy-mode.mjs) asks every time, since an app at the gateway may
154
- // have taken the lease without it hearing yet.
155
- if ((this.agent.viewer || this.agent.copy) && req.method !== 'GET' && req.method !== 'HEAD') {
152
+ // An agent whose state its gateway acts on too — its copy there, or with
153
+ // the gateway's hold off, its pod (the browser build, web/app/copy-mode.mjs)
154
+ // — asks every time, since an app at the gateway may have taken the lease
155
+ // without it hearing yet.
156
+ if ((this.agent.viewer || this.agent.sharesState) && req.method !== 'GET' && req.method !== 'HEAD') {
156
157
  const took = await this.agent.requestTakeover?.();
157
158
  if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
158
159
  }
@@ -4,6 +4,7 @@
4
4
  // the three actions that answer as a connected account rather than the pod
5
5
  // actor (a Bluesky reply, a connected-account reply, a like or boost there).
6
6
 
7
+ import { totalsFor } from '../../core/intake/counts.mjs';
7
8
  import * as social from '../../core/social.mjs';
8
9
  import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire.mjs';
9
10
  import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
@@ -265,6 +266,8 @@ export function quoteApproval(api, s) {
265
266
 
266
267
  export function status(api, s, { all, depth = 0 } = {}) {
267
268
  const replies = (all || api.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
269
+ // The owner's own posts: what others did to them. Zero for anyone else's.
270
+ const counted = totalsFor(api.store, s.noteId);
268
271
  return {
269
272
  id: api.store.idFor(s.noteId),
270
273
  created_at: s.published || new Date().toISOString(),
@@ -274,7 +277,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
274
277
  visibility: s.visibility || 'public', language: null,
275
278
  edited_at: s.editedAt || null,
276
279
  uri: s.noteId, url: s.link || s.noteId,
277
- replies_count: replies, reblogs_count: 0, favourites_count: 0,
280
+ replies_count: replies, reblogs_count: counted.shares, favourites_count: counted.likes,
278
281
  // True when ANY of the owner's accounts holds it. The flag is really
279
282
  // what the next tap will do: an empty star on a post one account has
280
283
  // already liked invites a second outward like from a second identity.
@@ -2,6 +2,7 @@
2
2
  // from Intake.handle() after the sender passed the door checks, and returns a
3
3
  // rejection reason or nothing.
4
4
 
5
+ import { withdrawReaction } from './counts.mjs';
5
6
  import { dropFollower } from '../store.mjs';
6
7
  import { emojisOf, pollOf, isContentType, trimActivity, reactionOf, quoteOf, quotePolicyOf, authorOf } from './activity.mjs';
7
8
 
@@ -40,20 +41,17 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
40
41
  // anyone at all, and we would sign an Accept, deliver it to that person,
41
42
  // and send them everything published from then on.
42
43
  //
43
- // Until deliveries terminate somewhere their signature survives, a follow
44
- // we cannot verify is a REQUEST, waiting in the same queue a gated group
45
- // uses. The requester's client shows "Requested", which is the ordinary
46
- // locked-account state that manuallyApprovesFollowers tells it to expect.
47
- // `autoAcceptFollows: true` in config restores the old behaviour.
48
- // A GROUP is left alone: `approveJoins: false` is its operator saying, in
49
- // as many words, that anyone may join, and mute/eject are the remedy there.
50
- // A person has no such setting, so this is their default.
44
+ // A person who set `autoAcceptFollows: false` sees such a follow as a
45
+ // REQUEST, waiting in the same queue a gated group uses; the requester's
46
+ // client shows "Requested", the locked-account state manuallyApprovesFollowers
47
+ // tells it to expect. Otherwise it is accepted, as a group's is:
48
+ // `approveJoins: false` is its operator saying anyone may join.
51
49
  //
52
50
  // A gateway-verified Follow (trust mode) is no longer unverifiable — the
53
51
  // door proved the sender — so it does not need the OK that unverifiability
54
52
  // alone demanded. An explicit `approveJoins` still holds: verified or not,
55
53
  // the operator asked to see joins.
56
- const unverifiedNeedsOk = intake.config.kind !== 'group' && !intake.config.autoAcceptFollows && !trusted;
54
+ const unverifiedNeedsOk = intake.config.kind !== 'group' && intake.config.autoAcceptFollows === false && !trusted;
57
55
  const mustApprove = intake.config.approveJoins || unverifiedNeedsOk;
58
56
  if (mustApprove && !existing) {
59
57
  const reqs = intake.store.getRequests();
@@ -319,6 +317,7 @@ export async function onUndoReaction(intake, inner, actor, { trusted = false } =
319
317
  const type = inner.type === 'Like' ? 'favourite' : 'reblog';
320
318
  const gone = intake.store.removeNotifications(n => n.type === type && n.actor === actor && n.noteId === objectId);
321
319
  if (gone) intake.log(`${inner.type} withdrawn by ${actor} on ${objectId}`);
320
+ withdrawReaction(intake.store, inner.type, objectId, actor);
322
321
  return;
323
322
  }
324
323
  if (inner.type !== 'Announce') return;
@@ -0,0 +1,143 @@
1
+ // counts.mjs — how many liked and boosted each of the owner's posts, and the
2
+ // totals on the post itself (ActivityPub §5.7, §5.8), so a server showing the
3
+ // post shows its real numbers instead of none.
4
+ //
5
+ // Who liked is kept here, in state, only to tell a repeat from a new one and
6
+ // to take back the right one on an Undo; the post says the total and names
7
+ // nobody. Past MAX_NAMED on one post the total still grows, but a repeat by
8
+ // someone past the cap can no longer be told apart.
9
+ //
10
+ // The first sweep that finds no totals kept starts them from the account's
11
+ // notifications, which hold its recent likes and boosts; older ones are gone.
12
+ //
13
+ // The post is rewritten once per sweep for every post whose totals changed,
14
+ // and only over the version it was read at: an edit or a deletion made in
15
+ // between wins, and the totals are written on a later sweep. The posts left
16
+ // to rewrite are kept in state, so a restart does not lose them.
17
+
18
+ import * as podNotes from '../../pod/notes.mjs';
19
+ import * as wire from '../wire.mjs';
20
+
21
+ export const MAX_NAMED = 1000;
22
+ export const MAX_POSTS = 2000;
23
+ export const REWRITES_PER_SWEEP = 20;
24
+ const COUNTS = 'counts.json';
25
+ const KIND = { Like: 'likes', Announce: 'shares' };
26
+ const MORE = { likes: 'moreLikes', shares: 'moreShares' };
27
+
28
+ const load = (store) => {
29
+ const st = typeof store?.read === 'function' ? store.read(COUNTS, null) : null;
30
+ return st && typeof st === 'object' && st.posts && typeof st.posts === 'object'
31
+ ? { posts: st.posts, dirty: Array.isArray(st.dirty) ? st.dirty : [] }
32
+ : { posts: {}, dirty: [] };
33
+ };
34
+
35
+ const totals = (p) => ({
36
+ likes: (p?.likes?.length || 0) + (p?.moreLikes || 0),
37
+ shares: (p?.shares?.length || 0) + (p?.moreShares || 0),
38
+ });
39
+
40
+ /** The totals for one post. Zero for a post nobody liked or boosted. */
41
+ export function totalsFor(store, noteId) {
42
+ // Read without a copy: the Mastodon API asks this for every post it lists.
43
+ const st = store?.cache?.get?.(COUNTS) ?? store?.read?.(COUNTS, null);
44
+ return totals(st?.posts?.[noteId]);
45
+ }
46
+
47
+ function save(store, st, noteId) {
48
+ if (typeof store?.write !== 'function') return;
49
+ if (noteId && !st.dirty.includes(noteId)) st.dirty.push(noteId);
50
+ const ids = Object.keys(st.posts);
51
+ if (ids.length > MAX_POSTS) {
52
+ // The posts touched longest ago go first.
53
+ ids.sort((a, b) => String(st.posts[a].at || '').localeCompare(String(st.posts[b].at || '')));
54
+ for (const id of ids.slice(0, ids.length - MAX_POSTS)) delete st.posts[id];
55
+ st.dirty = st.dirty.filter(id => st.posts[id]);
56
+ }
57
+ store.write(COUNTS, st);
58
+ }
59
+
60
+ /** A Like or an Announce of one of the owner's posts. True when a total changed. */
61
+ export function recordReaction(store, type, noteId, actor) {
62
+ const kind = KIND[type];
63
+ if (!kind || !noteId || !actor) return false;
64
+ const st = load(store);
65
+ const p = st.posts[noteId] || { likes: [], shares: [] };
66
+ const named = p[kind] || [];
67
+ if (named.includes(actor)) return false;
68
+ if (named.length >= MAX_NAMED) p[MORE[kind]] = (p[MORE[kind]] || 0) + 1;
69
+ else p[kind] = [...named, actor];
70
+ p.at = new Date().toISOString();
71
+ st.posts[noteId] = p;
72
+ save(store, st, noteId);
73
+ return true;
74
+ }
75
+
76
+ /** An Undo of one. True when a total changed. */
77
+ export function withdrawReaction(store, type, noteId, actor) {
78
+ const kind = KIND[type];
79
+ if (!kind || !noteId || !actor) return false;
80
+ const st = load(store);
81
+ const p = st.posts[noteId];
82
+ if (!p) return false;
83
+ const named = p[kind] || [];
84
+ if (named.includes(actor)) p[kind] = named.filter(a => a !== actor);
85
+ else if (p[MORE[kind]] > 0) p[MORE[kind]] -= 1;
86
+ else return false;
87
+ p.at = new Date().toISOString();
88
+ save(store, st, noteId);
89
+ return true;
90
+ }
91
+
92
+ /** A deleted post keeps no totals. */
93
+ export function forgetCounts(store, noteId) {
94
+ const st = load(store);
95
+ if (!st.posts[noteId] && !st.dirty.includes(noteId)) return;
96
+ delete st.posts[noteId];
97
+ st.dirty = st.dirty.filter(id => id !== noteId);
98
+ save(store, st, null);
99
+ }
100
+
101
+ // Totals from before they were kept: the likes and boosts still in the
102
+ // notifications. Runs once, the first time there are no totals at all.
103
+ function seed(intake) {
104
+ const { store } = intake;
105
+ if (typeof store?.read !== 'function' || store.read(COUNTS, null)) return;
106
+ const notes = intake.urls?.notes;
107
+ const all = typeof store.getNotifications === 'function' ? store.getNotifications() : [];
108
+ for (const n of [...all].reverse()) { // oldest first, as they arrived
109
+ if (!notes || !String(n?.noteId || '').startsWith(notes)) continue;
110
+ if (n.type === 'favourite') recordReaction(store, 'Like', n.noteId, n.actor);
111
+ else if (n.type === 'reblog') recordReaction(store, 'Announce', n.noteId, n.actor);
112
+ }
113
+ if (!store.read(COUNTS, null)) store.write(COUNTS, { posts: {}, dirty: [] });
114
+ }
115
+
116
+ /**
117
+ * Write the changed totals onto the posts. Returns how many posts were
118
+ * rewritten. A post the pod no longer has, or has as a Tombstone, is dropped.
119
+ */
120
+ export async function publishCounts(intake) {
121
+ seed(intake);
122
+ const st = load(intake.store);
123
+ if (!st.dirty.length) return 0;
124
+ const left = [];
125
+ let wrote = 0;
126
+ for (const noteId of st.dirty.slice(0, REWRITES_PER_SWEEP)) {
127
+ try {
128
+ const { doc, version } = await podNotes.readVersioned(intake.remote, noteId);
129
+ if (!doc || typeof doc !== 'object' || !doc.id || doc.type === 'Tombstone') { delete st.posts[noteId]; continue; }
130
+ const t = totals(st.posts[noteId]);
131
+ const next = { ...doc, ...wire.reactionCounts(t.likes, t.shares) };
132
+ if (JSON.stringify([next.likes, next.shares]) === JSON.stringify([doc.likes, doc.shares])) continue;
133
+ if (await podNotes.writeIfUnchanged(intake.remote, doc.id, next, version)) wrote++;
134
+ else left.push(noteId); // changed under us: the next sweep reads it again
135
+ } catch (e) {
136
+ intake.log?.(`like and boost totals for ${noteId}: ${e.message}`);
137
+ left.push(noteId);
138
+ }
139
+ }
140
+ st.dirty = [...left, ...st.dirty.slice(REWRITES_PER_SWEEP)].filter(id => st.posts[id]);
141
+ intake.store.write(COUNTS, st);
142
+ return wrote;
143
+ }
@@ -28,6 +28,8 @@ import { trimActivity, sameIdentity, sameOrigin, httpUrl, reactionOf, MAX_EMOJIS
28
28
  import * as channel from './channel.mjs';
29
29
  import * as verify from './verify.mjs';
30
30
  import * as group from './group.mjs';
31
+ import * as received from './received.mjs';
32
+ import * as counts from './counts.mjs';
31
33
  import * as activities from './activities.mjs';
32
34
  import * as notes from './notes.mjs';
33
35
  import { readLenient } from '../as2.mjs';
@@ -187,6 +189,8 @@ export class Intake {
187
189
  this._draining = this._drainOnce().finally(async () => {
188
190
  this._inSweep = false;
189
191
  await this._publishPending();
192
+ // Likes and boosts this sweep took, onto the posts: one write per post.
193
+ await counts.publishCounts(this).catch((e) => this.log(`like and boost totals: ${e.message}`));
190
194
  this.store.release?.();
191
195
  this._draining = null;
192
196
  if (this._drainAgain) {
@@ -489,6 +493,9 @@ export class Intake {
489
493
  if (pending.length >= DELETE_BATCH && !await flush()) return;
490
494
  }
491
495
  if (!await this._finishSweep(flush)) return;
496
+ // What this sweep accepted, onto the pod as the owner's inbox collection:
497
+ // one page write and the head per sweep with mail, never per item.
498
+ await received.publishReceived(this).catch((e) => this.log(`inbox collection: ${e.message}`));
492
499
  // Receipts earlier drains left behind, a few per sweep until none remain.
493
500
  for (const stray of podInbox.orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
494
501
  if (!await podInbox.dropStrayReceipt(this.remote, stray)) break;
@@ -532,7 +539,10 @@ export class Intake {
532
539
  const rejection = !activity ? 'unparsable JSON'
533
540
  : owned ? await this.ownerPostFrom(activity, raw, receipt)
534
541
  : await this.handle(activity, receipt);
535
- if (!rejection && raw && !owned) await this._archive(url, raw, activity);
542
+ if (!rejection && raw && !owned) {
543
+ await this._archive(url, raw, activity);
544
+ received.recordReceived(this, activity); // §5.2: the owner's readable inbox
545
+ }
536
546
  if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
537
547
  if (rejection) {
538
548
  this.store.addDeadLetter({
@@ -760,6 +770,7 @@ export class Intake {
760
770
  type: activity.type === 'Like' ? 'favourite' : 'reblog', actor, noteId: objectId,
761
771
  ...(this.known(actor) ? {} : { unverified: true }),
762
772
  });
773
+ counts.recordReaction(this.store, activity.type, objectId, actor); // §5.7, §5.8
763
774
  return;
764
775
  }
765
776
  if (activity.type === 'Announce') return this.onAnnounce(activity, actor, objectId);