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.
- package/README.md +8 -13
- package/architecture.md +5 -2
- package/gateway.md +56 -24
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +23 -74
- 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 +12 -1
- package/lib/core/intake/received.mjs +78 -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 +18 -9
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +98 -0
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/lib/surface/routes/owner.mjs +2 -2
- package/package.json +2 -2
- 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 +12 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +582 -282
- 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
|
|
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.
|
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
|
|
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
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
|
-
|
|
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
|
|
77
|
-
|
|
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.**
|
|
176
|
-
|
|
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.
|
|
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
|
|
188
|
-
keeps your account running,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
minutes,
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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,
|
|
226
|
-
|
|
227
|
-
|
|
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,
|
|
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
|
|
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
|
@@ -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
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 = `${
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
doc = await this.agent.remote.getJson(page ? `${own}-${page}` : own);
|
|
125
111
|
} catch (e) {
|
|
126
|
-
return this.send(res, 502, { error: `the
|
|
112
|
+
return this.send(res, 502, { error: `the inbox collection could not be read: ${e.message}` });
|
|
127
113
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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)
|
|
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);
|