fedipod 1.45.0 → 1.48.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 +11 -16
- package/architecture.md +6 -3
- package/gateway.md +51 -23
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +2 -1
- package/lib/client/masto/index.mjs +5 -4
- package/lib/client/masto/render.mjs +4 -1
- package/lib/core/intake/activities.mjs +8 -9
- package/lib/core/intake/counts.mjs +143 -0
- package/lib/core/intake/index.mjs +4 -0
- package/lib/core/publisher/notes.mjs +4 -1
- package/lib/core/social.mjs +2 -0
- package/lib/core/storage.mjs +54 -5
- package/lib/core/wire.mjs +16 -5
- package/lib/gateway/account-agent.mjs +20 -4
- package/lib/gateway/copy.mjs +95 -5
- package/lib/gateway/front-core.mjs +4 -4
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +10 -3
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/server/embed.mjs +1 -1
- package/lib/session/README.md +2 -135
- package/lib/surface/routes/owner.mjs +2 -2
- package/lib/surface/surface.mjs +4 -3
- package/package.json +5 -3
- package/scripts/check-netlify-bundles.mjs +0 -4
- package/scripts/check-pod-calls.mjs +5 -108
- package/scripts/check-sizes.mjs +6 -3
- package/scripts/dependents.mjs +76 -0
- package/scripts/stage-site.mjs +10 -8
- package/scripts/start.mjs +2 -2
- package/scripts/test-dependents.mjs +37 -0
- package/web/admin/gateway.js +8 -24
- package/web/admin/index.html +0 -16
- package/web/admin/record.js +1 -2
- package/web/admin/upkeep.js +1 -5
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +27 -10
- package/web/app/copy-mode.mjs +98 -13
- package/web/app/dist/boot.js +68 -57
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +583 -283
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/forum-support.mjs +2 -2
- package/web/app/oidc-session.mjs +2 -2
- package/lib/session/demo.html +0 -94
- package/lib/session/fedi-account.mjs +0 -417
- package/lib/session/fedi-login.mjs +0 -123
- package/lib/session/oidc-session.mjs +0 -233
- package/lib/session/package.json +0 -49
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
|
|
9
|
-
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
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
@@ -142,11 +137,11 @@ not change.
|
|
|
142
137
|
|
|
143
138
|
## Other ways to use FediPod
|
|
144
139
|
|
|
145
|
-
- [fediverse-session](
|
|
146
|
-
- [FediPod Server](
|
|
140
|
+
- [fediverse-session](https://github.com/jeff-zucker/fediverse-session) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
|
|
141
|
+
- [FediPod Server](https://github.com/jeff-zucker/fedipod-server): a full ActivityPub
|
|
147
142
|
server as a Community Solid Server component, giving every pod on the server
|
|
148
143
|
the option of a Fediverse account.
|
|
149
|
-
- [FediPod BB](
|
|
144
|
+
- [FediPod BB](https://github.com/jeff-zucker/fedipod-bb) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
|
|
150
145
|
- [FediPod DeviceAgent](device-agent.md) a single-user AP server that lives partly in a gateway, partly on a local device, with all data stored on your pod.
|
|
151
146
|
- [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
|
|
152
147
|
- [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
|
package/architecture.md
CHANGED
|
@@ -21,10 +21,13 @@ 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
|
|
25
|
-
|
|
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
|
-
[FediPod Server](
|
|
30
|
+
[FediPod Server](https://github.com/jeff-zucker/fedipod-server) puts the agent inside a
|
|
28
31
|
Community Solid Server, so anyone with a pod on that server can opt in to a
|
|
29
32
|
Fediverse account fed by the server itself.
|
|
30
33
|
|
package/gateway.md
CHANGED
|
@@ -15,7 +15,7 @@ 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
|
-
|
|
18
|
+
held at the gateway while it works, for at most fifteen minutes at a time. For
|
|
19
19
|
a kept account the gateway also answers a follower's server asking for one
|
|
20
20
|
of your followers-only posts, and the named person's server asking for a
|
|
21
21
|
direct one, when that server signs its request; nobody else gets them.
|
|
@@ -174,28 +174,57 @@ closed, fedipod.net does two things for it.
|
|
|
174
174
|
your pod one by one, and reach your pod in batches of up to a hundred: when you
|
|
175
175
|
open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
|
|
176
176
|
goes straight to your pod as before. A DeviceAgent's mail always goes straight
|
|
177
|
-
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.
|
|
178
179
|
|
|
179
|
-
**The gateway keeps your account running.**
|
|
180
|
-
|
|
181
|
-
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
|
|
182
182
|
pod for FediPod to open, as before. It works under its own pod identity, which your app
|
|
183
183
|
names in the access rules on your FediPod folder. It reads your signing key
|
|
184
184
|
from your pod when it needs it, so it can post as you, and keeps no copy of the
|
|
185
|
-
key.
|
|
186
|
-
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
|
|
187
186
|
account over the next time you open it, with nothing for you to do.
|
|
188
187
|
Scheduling a post is offered only while this is on, because otherwise nothing
|
|
189
188
|
would be running when the time came.
|
|
190
189
|
|
|
191
|
-
**Your account's working data is
|
|
192
|
-
keeps your account running,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
minutes,
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
199
228
|
|
|
200
229
|
## Using any Mastodon app
|
|
201
230
|
|
|
@@ -204,7 +233,8 @@ elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
|
|
|
204
233
|
your server. The app sends you to a fedipod.net page that asks for your
|
|
205
234
|
Fediverse address or your WebID and signs you in at your own pod; no password
|
|
206
235
|
is typed on fedipod.net. The app then reads your timeline and notifications from your
|
|
207
|
-
account's copy at the gateway
|
|
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.
|
|
208
238
|
|
|
209
239
|
An app checks for new posts every minute or so while it is open; there are no
|
|
210
240
|
live updates. Accounts are made on the fedipod.net front page, with a pod, not
|
|
@@ -226,13 +256,11 @@ about each browser account — when its owner last signed in or posted, and
|
|
|
226
256
|
how much content has arrived since — and acts on them.
|
|
227
257
|
|
|
228
258
|
**Paused.** After about 5,000 posts, replies, likes, boosts and edits since
|
|
229
|
-
you were last here,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
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.
|
|
233
262
|
|
|
234
|
-
**Closed.** After six months without a sign-in,
|
|
235
|
-
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
|
|
236
264
|
door answer 410 Gone, other servers drop the account the next time they look,
|
|
237
265
|
and nobody can take the name. Nothing on your pod is touched. An address that
|
|
238
266
|
had already moved to another gateway keeps answering as moved.
|
|
@@ -280,7 +308,7 @@ Two ways are ready to use:
|
|
|
280
308
|
[netlify/README.md](netlify/README.md) for the deployment specifics.
|
|
281
309
|
- **On any box of your own**, with your own adapter around the same core.
|
|
282
310
|
- **Inside a Community Solid Server**, as the same door run in-process by the
|
|
283
|
-
CSS component — see [
|
|
311
|
+
CSS component — see [fedipod-server](https://github.com/jeff-zucker/fedipod-server).
|
|
284
312
|
|
|
285
313
|
### Offering accounts to other people
|
|
286
314
|
|
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
|
|
101
|
-
|
|
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
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -260,7 +260,8 @@ export class C2S {
|
|
|
260
260
|
// A viewer (another agent holds the lease) may not act — but the user
|
|
261
261
|
// acting HERE outranks an idle active agent elsewhere, exactly as on the
|
|
262
262
|
// facade: a write attempt claims the lease, and only a failed claim 503s.
|
|
263
|
-
|
|
263
|
+
// So does an agent whose state its gateway acts on too (sharesState).
|
|
264
|
+
if (this.agent.viewer || this.agent.sharesState) {
|
|
264
265
|
const took = await this.agent.requestTakeover?.();
|
|
265
266
|
if (!took) return this.send(res, 503, { error: 'another agent is active for this pod — takeover failed, try again' });
|
|
266
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
|
|
153
|
-
// web/app/copy-mode.mjs)
|
|
154
|
-
//
|
|
155
|
-
|
|
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:
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// `
|
|
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' &&
|
|
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
|
+
}
|
|
@@ -29,6 +29,7 @@ import * as channel from './channel.mjs';
|
|
|
29
29
|
import * as verify from './verify.mjs';
|
|
30
30
|
import * as group from './group.mjs';
|
|
31
31
|
import * as received from './received.mjs';
|
|
32
|
+
import * as counts from './counts.mjs';
|
|
32
33
|
import * as activities from './activities.mjs';
|
|
33
34
|
import * as notes from './notes.mjs';
|
|
34
35
|
import { readLenient } from '../as2.mjs';
|
|
@@ -188,6 +189,8 @@ export class Intake {
|
|
|
188
189
|
this._draining = this._drainOnce().finally(async () => {
|
|
189
190
|
this._inSweep = false;
|
|
190
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}`));
|
|
191
194
|
this.store.release?.();
|
|
192
195
|
this._draining = null;
|
|
193
196
|
if (this._drainAgain) {
|
|
@@ -767,6 +770,7 @@ export class Intake {
|
|
|
767
770
|
type: activity.type === 'Like' ? 'favourite' : 'reblog', actor, noteId: objectId,
|
|
768
771
|
...(this.known(actor) ? {} : { unverified: true }),
|
|
769
772
|
});
|
|
773
|
+
counts.recordReaction(this.store, activity.type, objectId, actor); // §5.7, §5.8
|
|
770
774
|
return;
|
|
771
775
|
}
|
|
772
776
|
if (activity.type === 'Announce') return this.onAnnounce(activity, actor, objectId);
|
|
@@ -7,6 +7,7 @@ import * as wire from '../wire.mjs';
|
|
|
7
7
|
import * as wireQuotes from '../wire-quotes.mjs';
|
|
8
8
|
import * as containers from '../../pod/containers.mjs';
|
|
9
9
|
import * as podNotes from '../../pod/notes.mjs';
|
|
10
|
+
import { totalsFor } from '../intake/counts.mjs';
|
|
10
11
|
|
|
11
12
|
// Media container on the remote pod — public-Read like notes, created lazily
|
|
12
13
|
// at first upload (idempotent; the flag only saves round-trips).
|
|
@@ -177,7 +178,7 @@ export async function noteToSelf(publisher, text) {
|
|
|
177
178
|
// What the object is, who made it and when, where it lives and who it is
|
|
178
179
|
// addressed to stay as they are, and so do a poll's choices and its count.
|
|
179
180
|
const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
|
|
180
|
-
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
|
|
181
|
+
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies', 'likes', 'shares']);
|
|
181
182
|
export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
|
|
182
183
|
const { urls } = publisher;
|
|
183
184
|
const doc = await podNotes.read(publisher.remote, s.noteId);
|
|
@@ -485,6 +486,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
485
486
|
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
486
487
|
also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
|
|
487
488
|
quote: rowQuote(s),
|
|
489
|
+
...totalsFor(publisher.store, s.noteId),
|
|
488
490
|
});
|
|
489
491
|
await podNotes.write(publisher.remote, note.id, note);
|
|
490
492
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
@@ -537,6 +539,7 @@ async function restateNote(publisher, s, { quote }) {
|
|
|
537
539
|
summary: s.spoiler || null, sensitive: !!s.sensitive, updated: s.editedAt || null, container,
|
|
538
540
|
also: [...(reply ? [reply.actor] : []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])],
|
|
539
541
|
quote,
|
|
542
|
+
...totalsFor(publisher.store, s.noteId),
|
|
540
543
|
});
|
|
541
544
|
await podNotes.write(publisher.remote, note.id, note);
|
|
542
545
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
package/lib/core/social.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the activity and mirrors state into the store; the store keeps the
|
|
4
4
|
// activity a later Undo must name.
|
|
5
5
|
|
|
6
|
+
import { forgetCounts } from './intake/counts.mjs';
|
|
6
7
|
import crypto from 'node:crypto';
|
|
7
8
|
import * as wire from './wire.mjs';
|
|
8
9
|
import * as podNotes from '../pod/notes.mjs';
|
|
@@ -424,6 +425,7 @@ export async function deleteNote(agent, s) {
|
|
|
424
425
|
}
|
|
425
426
|
// Empty, and referenced by nothing once the note is gone.
|
|
426
427
|
await podNotes.dropReplies(agent.remote, wire.repliesId(s.noteId));
|
|
428
|
+
forgetCounts(agent.store, s.noteId);
|
|
427
429
|
|
|
428
430
|
// The Bluesky mirror goes with the post. Best-effort: a mirror the far side
|
|
429
431
|
// already lost is not a reason to keep the post here.
|
package/lib/core/storage.mjs
CHANGED
|
@@ -177,21 +177,31 @@ export { podOnly };
|
|
|
177
177
|
* An account's state kept at its gateway, reached through the gateway's state
|
|
178
178
|
* API (lib/gateway/state-api.mjs) with the token it handed out. `holder` is
|
|
179
179
|
* this agent's lease id: the gateway takes a write only from the lease's
|
|
180
|
-
* holder, and `onRefused` hears when it would not.
|
|
181
|
-
*
|
|
180
|
+
* holder, and `onRefused` hears when it would not. `onGone` hears when the
|
|
181
|
+
* gateway keeps no copy of the account any more and will make none. What
|
|
182
|
+
* stays on the pod is read and written on `pod`.
|
|
182
183
|
*/
|
|
183
184
|
export class StateApiStorage {
|
|
184
|
-
constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null }) {
|
|
185
|
+
constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null, onGone = null }) {
|
|
185
186
|
this.base = slash(base);
|
|
186
187
|
this.fetchImpl = fetchImpl;
|
|
187
188
|
this.token = token;
|
|
188
189
|
this.holder = holder;
|
|
189
190
|
this.pod = pod;
|
|
190
191
|
this.onRefused = onRefused;
|
|
192
|
+
this.onGone = onGone;
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
get kind() { return 'copy'; }
|
|
194
196
|
|
|
197
|
+
async _gone(res) {
|
|
198
|
+
if (res.status !== 404 || !this.onGone) return false;
|
|
199
|
+
const said = await res.clone().json().catch(() => null);
|
|
200
|
+
if (!said?.gone) return false;
|
|
201
|
+
this.onGone();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
195
205
|
_ask(p, init = {}) {
|
|
196
206
|
return this.fetchImpl(this.base + encodeURIComponent(p), {
|
|
197
207
|
...init, headers: { authorization: `Bearer ${this.token}`, ...(init.headers || {}) },
|
|
@@ -202,7 +212,7 @@ export class StateApiStorage {
|
|
|
202
212
|
if (sub) return { notModified: false, names: [], etag: null };
|
|
203
213
|
const res = await this._ask('', { headers: etag ? { 'if-none-match': etag } : {} });
|
|
204
214
|
if (res.status === 304) return { notModified: true, names: null, etag };
|
|
205
|
-
if (res.status >= 400) throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`);
|
|
215
|
+
if (res.status >= 400) { await this._gone(res); throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`); }
|
|
206
216
|
const { names } = await res.json();
|
|
207
217
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
208
218
|
}
|
|
@@ -211,7 +221,7 @@ export class StateApiStorage {
|
|
|
211
221
|
if (podOnly(p)) return this.pod ? this.pod.read(p, opts) : { ok: false, notModified: false, status: 404, body: null, etag: null };
|
|
212
222
|
const res = await this._ask(p, { headers: opts.etag ? { 'if-none-match': opts.etag } : {} });
|
|
213
223
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag: opts.etag };
|
|
214
|
-
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
|
224
|
+
if (res.status >= 400) { await this._gone(res); return { ok: false, notModified: false, status: res.status, body: null, etag: null }; }
|
|
215
225
|
return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
|
|
216
226
|
}
|
|
217
227
|
|
|
@@ -221,6 +231,7 @@ export class StateApiStorage {
|
|
|
221
231
|
const res = await this._ask(p, { method: 'PUT', headers: { 'content-type': contentType, 'x-fedipod-holder': this.holder }, body });
|
|
222
232
|
if (res.status < 400) return { ok: true, retry: false, why: '' };
|
|
223
233
|
if (res.status === 409) { this.onRefused?.(); return { ok: false, retry: false, why: 'another agent holds this account now', lost: true }; }
|
|
234
|
+
if (await this._gone(res)) return { ok: false, retry: false, why: 'the gateway keeps no copy of this account now', lost: true };
|
|
224
235
|
return { ok: false, retry: res.status >= 500 || res.status === 429, why: `HTTP ${res.status}`, retryAfterMs: 0 };
|
|
225
236
|
} catch (e) {
|
|
226
237
|
return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
|
|
@@ -232,11 +243,49 @@ export class StateApiStorage {
|
|
|
232
243
|
try {
|
|
233
244
|
const res = await this._ask(p, { method: 'DELETE', headers: { 'x-fedipod-holder': this.holder } });
|
|
234
245
|
if (res.status === 409) { this.onRefused?.(); return false; }
|
|
246
|
+
if (await this._gone(res)) return false;
|
|
235
247
|
return res.status < 400 || res.status === 404;
|
|
236
248
|
} catch { return false; }
|
|
237
249
|
}
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A pod's state container that more than one agent may act on: the owner's
|
|
254
|
+
* browser, and the gateway acting for an app while the hold is off. With a
|
|
255
|
+
* `fence` set, every write first asks it whether this agent still holds the
|
|
256
|
+
* account, and a "no" is refused and told to `onRefused`, as the gateway's
|
|
257
|
+
* copy refuses a write from anyone but its lease's holder. Without one, it is
|
|
258
|
+
* the storage it wraps.
|
|
259
|
+
*/
|
|
260
|
+
export class FencedStorage {
|
|
261
|
+
constructor(inner, { fence = null, onRefused = null } = {}) {
|
|
262
|
+
this.inner = inner;
|
|
263
|
+
this.fence = fence;
|
|
264
|
+
this.onRefused = onRefused;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get kind() { return this.inner.kind; }
|
|
268
|
+
get base() { return this.inner.base; }
|
|
269
|
+
list(sub, opts) { return this.inner.list(sub, opts); }
|
|
270
|
+
read(p, opts) { return this.inner.read(p, opts); }
|
|
271
|
+
|
|
272
|
+
async _refused() {
|
|
273
|
+
if (!this.fence || await this.fence().catch(() => true)) return false;
|
|
274
|
+
this.onRefused?.();
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async write(p, body, contentType) {
|
|
279
|
+
if (await this._refused()) return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
|
|
280
|
+
return this.inner.write(p, body, contentType);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async remove(p) {
|
|
284
|
+
if (await this._refused()) return false;
|
|
285
|
+
return this.inner.remove(p);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
240
289
|
// `file:` (or a bare path) is a directory; anything else is a pod.
|
|
241
290
|
export function storageFor(base, fetchImpl) {
|
|
242
291
|
return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);
|