fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
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
|
-
one timeline. Your posts, followers and settings
|
|
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
10
|
|
|
9
11
|
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.
|
|
10
12
|
|
|
@@ -23,6 +25,11 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
|
|
|
23
25
|
2. Choose your pod provider. You'll be redirected to your provider to login if you have a pod or sign up if you need to create one.
|
|
24
26
|
3. Back on fedipod.net, choose your handle and where your address lives:
|
|
25
27
|
on your pod, `@handle@yourpod`, or at this site, `@handle@fedipod.net`.
|
|
28
|
+
Choose where on your pod your data goes: a container named `fedipod`,
|
|
29
|
+
inside whichever container you name (your pod's root if you leave it
|
|
30
|
+
empty). If your pod has no public type index yet, you're asked whether
|
|
31
|
+
to create one; FediPod records there where your account lives, and
|
|
32
|
+
nothing is set up without it.
|
|
26
33
|
4. You see your Fediverse timeline.
|
|
27
34
|
|
|
28
35
|
From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
|
|
@@ -71,6 +78,10 @@ an alias, so a Move from it lands here.
|
|
|
71
78
|
client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
72
79
|
address in your actor document, which your WebID profile also names, signed in
|
|
73
80
|
at your pod. The post goes out the next time you open fedipod.net.
|
|
81
|
+
If your account turns a post down when it runs, you get a direct message from
|
|
82
|
+
yourself saying what was refused and why. Signed in, your outbox shows you
|
|
83
|
+
everything you've done, likes and follows included; everyone else sees only
|
|
84
|
+
your public posts.
|
|
74
85
|
|
|
75
86
|
**The manage page.** `manage`, in the bar's account group, opens it: your profile,
|
|
76
87
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
@@ -83,14 +94,16 @@ there it takes over; the first drops back to reading.
|
|
|
83
94
|
|
|
84
95
|
## What the browser version does not do
|
|
85
96
|
|
|
86
|
-
- **Scheduled posts
|
|
97
|
+
- **Scheduled posts, if you stop fedipod.net keeping your account running.**
|
|
98
|
+
It does by default, and publishes them while FediPod is closed. Turned off
|
|
99
|
+
on the manage page, nothing runs between now and the time you picked, so
|
|
87
100
|
the composer refuses one rather than dropping it later.
|
|
88
|
-
- **Notifications
|
|
89
|
-
|
|
101
|
+
- **Notifications, and other clients, if you stop fedipod.net keeping your
|
|
102
|
+
account running.** While it does (the default), any Mastodon app, phone or
|
|
103
|
+
desktop, can use your account with `fedipod.net` as its server, and an app
|
|
104
|
+
that offers notifications gets them on your phone with everything closed.
|
|
90
105
|
- **Live updates.** The client refreshes by polling.
|
|
91
106
|
- **Hosting a group.** Joining one works.
|
|
92
|
-
- **Other clients.** A phone app or desktop client has nothing on the network
|
|
93
|
-
to connect to.
|
|
94
107
|
|
|
95
108
|
For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
96
109
|
|
|
@@ -98,7 +111,15 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
|
98
111
|
|
|
99
112
|
Everything you publish and everything you read is stored on your pod. Your
|
|
100
113
|
signing key is stored there too, in a container only you can read through
|
|
101
|
-
your pod's login.
|
|
114
|
+
your pod's login.
|
|
115
|
+
While fedipod.net keeps your account running, its own pod identity can read
|
|
116
|
+
that container too, so it can act for you while FediPod is closed, and it
|
|
117
|
+
works from a copy of your account's data that it writes back to your pod every
|
|
118
|
+
fifteen minutes. Your key stays on your pod only.
|
|
119
|
+
It all lives in the `fedipod` container you chose at sign-up. Your pod's
|
|
120
|
+
public type index records it, as an ActivityStreams actor, which is how
|
|
121
|
+
FediPod and other Solid apps find your account again.
|
|
122
|
+
fedipod.net holds no key: it verifies incoming mail, drops
|
|
102
123
|
the junk, forwards the rest to your pod, and hands your browser the app.
|
|
103
124
|
With an address on your pod, `@handle@yourpod`, you can detach from it at
|
|
104
125
|
any time and attach to a gateway of your own; your address and your data do
|
|
@@ -106,7 +127,7 @@ not change.
|
|
|
106
127
|
|
|
107
128
|
## Other ways to use FediPod
|
|
108
129
|
|
|
109
|
-
- [fediverse-
|
|
130
|
+
- [fediverse-session](lib/session/README.md) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
|
|
110
131
|
- [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
|
|
111
132
|
server as a Community Solid Server component, giving every pod on the server
|
|
112
133
|
the option of a Fediverse account.
|
package/architecture.md
CHANGED
|
@@ -16,6 +16,14 @@ and forwards the rest to the pod inbox with a receipt. It holds no key. The
|
|
|
16
16
|
browser version always has one; the DeviceAgent may use one. Any
|
|
17
17
|
lightweight host will do, Netlify included.
|
|
18
18
|
|
|
19
|
+
A gateway can also keep a browser account running. It then acts as that
|
|
20
|
+
account's agent whenever FediPod is closed, under a pod identity of its own
|
|
21
|
+
that the owner's rules name, reading the signing key from the pod when it
|
|
22
|
+
needs it. The account's state documents live in a working copy at the
|
|
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).
|
|
26
|
+
|
|
19
27
|
[FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
|
|
20
28
|
Community Solid Server, so anyone with a pod on that server can opt in to a
|
|
21
29
|
Fediverse account fed by the server itself.
|
package/bin/fedipod.mjs
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// --pod-name, --group, --summary, --icon, --root, --keys) keeps setup
|
|
26
26
|
// entirely on the command line, as does a non-TTY stdin. --cli forces it.
|
|
27
27
|
//
|
|
28
|
+
// --in <container> the container on the pod that holds `fedipod/`
|
|
29
|
+
// (asked when not given; empty is the pod's root)
|
|
30
|
+
// --create-type-index make a public type index when the pod has none;
|
|
31
|
+
// where the account lives is recorded there, and
|
|
32
|
+
// without one and without this, setup stops
|
|
33
|
+
//
|
|
28
34
|
// The password is prompted (or AP_PASSWORD) — used once to create the
|
|
29
35
|
// account and/or mint a revocable CSS client-credential, never stored.
|
|
30
36
|
// Keys live in AP_HOME by default (the pod host cannot read them);
|
package/cli.md
CHANGED
|
@@ -32,10 +32,16 @@ prompt. `--profile NAME` names the new identity when you have more than one.
|
|
|
32
32
|
`--address pod` (the default) puts your address on your pod, `@you@yourpod`;
|
|
33
33
|
`--address front` puts it at a gateway, `@you@the-gateway`, with your posts,
|
|
34
34
|
key and data still on your pod. `--gateway <url>` names the gateway
|
|
35
|
-
(`https://fedipod.net` by default). A pod
|
|
35
|
+
(`https://fedipod.net` by default). A suffixed pod cannot
|
|
36
36
|
answer WebFinger for a handle, so it always takes a gateway address, whichever
|
|
37
37
|
`--address` you gave.
|
|
38
38
|
|
|
39
|
+
Your data goes in a container named `fedipod`, inside the container you name
|
|
40
|
+
with `--in <path>` (asked when you leave it out; empty is your pod's root). Where
|
|
41
|
+
it lives is recorded in your pod's public type index. If your pod has none,
|
|
42
|
+
setup asks before making one, and `--create-type-index` says yes in advance;
|
|
43
|
+
without a yes, setup stops before anything is written.
|
|
44
|
+
|
|
39
45
|
## Which identity a command acts on
|
|
40
46
|
|
|
41
47
|
`--profile NAME` works on every command, not just `up`; `AP_PROFILE` is the
|
package/device-agent.md
CHANGED
|
@@ -12,9 +12,9 @@ connect to it; and it can host a [group](groups.md).
|
|
|
12
12
|
- Node 20 or newer.
|
|
13
13
|
- A Solid pod, such as `https://alice.solidcommunity.net/` or
|
|
14
14
|
`https://server.example/alice/`. The address is `@handle@yourpod`, or
|
|
15
|
-
`@handle@fedipod.net` when the pod is
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
`@handle@fedipod.net` when the pod is suffixed, since a suffixed pod
|
|
16
|
+
cannot answer for the handle; the posts, key and data stay on the pod
|
|
17
|
+
either way.
|
|
18
18
|
- Followers-only and direct posts need a pod that enforces WAC access control;
|
|
19
19
|
on one that does not, the composer refuses those two and says why.
|
|
20
20
|
- While the agent is off, your mail waits on your pod's host. Run it as a
|
package/gateway.md
CHANGED
|
@@ -6,13 +6,16 @@ each delivery's signature where the headers still exist, drops forgeries and
|
|
|
6
6
|
junk before they ever touch your pod, and passes the rest on with a receipt
|
|
7
7
|
saying it checked.
|
|
8
8
|
|
|
9
|
-
Your name, your signing key and your data
|
|
9
|
+
Your name, your signing key and your data are kept on your own pod. The gateway is
|
|
10
10
|
**keyless** — it never holds the key you sign with, so it cannot post as you,
|
|
11
11
|
read your private things, or be you anywhere. The worst a broken one can do is
|
|
12
12
|
push items into your inbox, and those still face your agent's own checks.
|
|
13
13
|
With the outbox door below, a gateway can also hand your agent a post marked as
|
|
14
14
|
yours, which your agent then signs and sends. It still holds no key, but what you
|
|
15
|
-
trust it with grows by that much.
|
|
15
|
+
trust it with grows by that much. A browser account is also kept running by
|
|
16
|
+
the gateway (below). That gives the gateway your key while it works, so it can
|
|
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.
|
|
16
19
|
|
|
17
20
|
A FediPod install works without any gateway at all. Deliveries go straight to
|
|
18
21
|
your pod inbox, which holds them whether your agent is running or not.
|
|
@@ -32,7 +35,7 @@ password is typed anywhere:
|
|
|
32
35
|
restarts the agent itself to publish under it.
|
|
33
36
|
|
|
34
37
|
In the BrowserAgent the same choice is made once, at sign-up, and cannot be
|
|
35
|
-
changed afterwards. A pod
|
|
38
|
+
changed afterwards. A suffixed pod, like
|
|
36
39
|
`https://server.example/alice/`, always takes the gateway-based name: nothing
|
|
37
40
|
at that host answers for the handle, so the gateway does. Its posts, key and
|
|
38
41
|
data stay on the pod.
|
|
@@ -153,10 +156,61 @@ on the setup page). Setup reads the account on the pod, keeps its state and
|
|
|
153
156
|
key there, attaches at the new gateway, and completes the move when the
|
|
154
157
|
agent first acts. See [the DeviceAgent](device-agent.md).
|
|
155
158
|
|
|
159
|
+
## While your app is closed
|
|
160
|
+
|
|
161
|
+
A browser account runs only while FediPod is open somewhere. While it is
|
|
162
|
+
closed, fedipod.net does two things for it.
|
|
163
|
+
|
|
164
|
+
**Your mail waits at the gateway.** Deliveries are kept there, not written to
|
|
165
|
+
your pod one by one, and reach your pod in batches of up to a hundred: when you
|
|
166
|
+
open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
|
|
167
|
+
goes straight to your pod as before. A DeviceAgent's mail always goes straight
|
|
168
|
+
to its pod.
|
|
169
|
+
|
|
170
|
+
**The gateway keeps your account running.** Unless you turn it off, the
|
|
171
|
+
gateway accepts your follows, tries again what failed to go out, and publishes
|
|
172
|
+
the posts you scheduled, all while FediPod is closed. Other mail waits on your
|
|
173
|
+
pod for FediPod to open, as before. It works under its own pod identity, which your app
|
|
174
|
+
names in the access rules on your FediPod folder. It reads your signing key
|
|
175
|
+
from your pod when it needs it, so it can post as you, and keeps no copy of the
|
|
176
|
+
key. **Stop keeping it running** on the manage page takes it out of the rules.
|
|
177
|
+
If fedipod.net ever changes its own pod identity, your FediPod moves your
|
|
178
|
+
account over the next time you open it, with nothing for you to do.
|
|
179
|
+
Scheduling a post is offered only while this is on, because otherwise nothing
|
|
180
|
+
would be running when the time came.
|
|
181
|
+
|
|
182
|
+
**Your account's working data is kept at the gateway.** While the gateway
|
|
183
|
+
keeps your account running, your timeline, notifications, followers, settings
|
|
184
|
+
and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
|
|
185
|
+
browser, the gateway and any app you use all work from that one copy, so they
|
|
186
|
+
never disagree. fedipod.net writes what changed to your pod every fifteen
|
|
187
|
+
minutes, and everything at once when you stop it keeping your account running,
|
|
188
|
+
move your address or close it. Your signing key and the passwords of accounts
|
|
189
|
+
you connected elsewhere are never in the copy; they stay on your pod only.
|
|
190
|
+
|
|
191
|
+
## Using any Mastodon app
|
|
192
|
+
|
|
193
|
+
While the gateway keeps your account running, any Mastodon app can use it:
|
|
194
|
+
elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
|
|
195
|
+
your server. The app sends you to a fedipod.net page that asks for your
|
|
196
|
+
address here and signs you in at your own pod; no password is typed on
|
|
197
|
+
fedipod.net. The app then reads your timeline and notifications from your
|
|
198
|
+
account's copy at the gateway, and posts, boosts, likes and follows as you.
|
|
199
|
+
|
|
200
|
+
An app checks for new posts every minute or so while it is open; there are no
|
|
201
|
+
live updates. Accounts are made on the fedipod.net front page, with a pod, not
|
|
202
|
+
from an app.
|
|
203
|
+
|
|
204
|
+
**Notifications on your phone.** An app that offers notifications can sign up
|
|
205
|
+
for them here. A mention, a reply, a direct message, a follow, or a like, boost
|
|
206
|
+
or reaction to your own post then reaches your phone as it arrives, while
|
|
207
|
+
FediPod and the app are closed. While FediPod is open in a browser, it reads
|
|
208
|
+
your mail itself, and those arrivals are not pushed.
|
|
209
|
+
|
|
156
210
|
## Accounts that go quiet
|
|
157
211
|
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
Every delivery the gateway accepts ends up in your pod inbox, and your agent
|
|
213
|
+
reads it from there. A BrowserAgent reads only while
|
|
160
214
|
its page is open, so an account nobody opens grows on its pod without limit
|
|
161
215
|
and comes back to a drain of everything at once. fedipod.net keeps two facts
|
|
162
216
|
about each browser account — when its owner last signed in or posted, and
|
|
@@ -194,9 +248,16 @@ web address becomes a link.
|
|
|
194
248
|
|
|
195
249
|
It reads only public data to decide what concerns you: your published
|
|
196
250
|
followers and following, and a small public policy document your agent writes
|
|
197
|
-
with a mirror of your blocklist.
|
|
198
|
-
|
|
199
|
-
|
|
251
|
+
with a mirror of your blocklist. Publishing that mirror does make your
|
|
252
|
+
blocklist public, which is part of the bargain of running behind a door.
|
|
253
|
+
|
|
254
|
+
Three things go further, all for browser accounts only. Mail that waits at
|
|
255
|
+
the gateway while your app is closed, direct messages included, is stored there
|
|
256
|
+
until it reaches your pod. A gateway keeping your account running can read and
|
|
257
|
+
write everything in your FediPod folder, your signing key included, while it
|
|
258
|
+
works; nothing else on your pod is open to it. And it keeps your account's
|
|
259
|
+
working data — your timeline, notifications and direct messages among it — for
|
|
260
|
+
as long as it keeps your account running.
|
|
200
261
|
|
|
201
262
|
## Running a gateway
|
|
202
263
|
|
package/groups.md
CHANGED
|
@@ -60,7 +60,8 @@ A group that withdraws a post it carried to you takes it off your timeline too.
|
|
|
60
60
|
## Inviting people
|
|
61
61
|
|
|
62
62
|
A group has a page anyone can open, at `ap/profile.html` under its pod's
|
|
63
|
-
app container — `<pod>/fedipod/ap/profile.html
|
|
63
|
+
app container — `<pod>/fedipod/ap/profile.html`
|
|
64
|
+
by default, or under the `fedipod` container wherever it was put at setup. It
|
|
64
65
|
carries the group's address and a Follow box that sends a visitor to their
|
|
65
66
|
own server's follow screen, so it is the link to put where people will find
|
|
66
67
|
it. Posts the group carries appear in members' timelines as the group
|
package/gui.md
CHANGED
|
@@ -46,7 +46,9 @@ its own port.
|
|
|
46
46
|
## Sharing an account
|
|
47
47
|
|
|
48
48
|
Each identity has a page anyone can open, at `ap/profile.html` under its pod
|
|
49
|
-
— for example `https://your-pod.example/fedipod/ap/profile.html
|
|
49
|
+
— for example `https://your-pod.example/fedipod/ap/profile.html`
|
|
50
|
+
(the `fedipod` container is wherever you put it at setup; the manage page's
|
|
51
|
+
"stored at" row says where). It
|
|
50
52
|
shows the name, bio and address, and offers a Follow box: a visitor types
|
|
51
53
|
their own server and lands on that server's follow screen. Hand out that
|
|
52
54
|
link, or the `@name@host` address itself, which works in the search box of
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import * as social from '../core/social.mjs';
|
|
|
28
28
|
import * as wire from '../core/wire.mjs';
|
|
29
29
|
import { readLenient } from '../core/as2.mjs';
|
|
30
30
|
import { safeSlug } from '../core/publisher/notes.mjs';
|
|
31
|
+
import * as podNotes from '../pod/notes.mjs';
|
|
31
32
|
|
|
32
33
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
33
34
|
|
|
@@ -192,9 +193,11 @@ export class C2S {
|
|
|
192
193
|
* published copy and it stays the canonical one; this is served only where
|
|
193
194
|
* that copy cannot be read by the client asking.
|
|
194
195
|
*/
|
|
195
|
-
sendLocalOutbox(res, url, origin) {
|
|
196
|
+
sendLocalOutbox(res, url, origin, { owner = false } = {}) {
|
|
196
197
|
const id = `${origin}ap/outbox`;
|
|
197
|
-
|
|
198
|
+
// newest first; the owner's view is every message, the public one what a
|
|
199
|
+
// stranger may see
|
|
200
|
+
const outbox = this.store.read(owner ? 'outbox-own.json' : 'outbox.json', []);
|
|
198
201
|
const page = url?.searchParams?.get('page') || null;
|
|
199
202
|
const ct = { 'content-type': 'application/activity+json; charset=utf-8' };
|
|
200
203
|
if (!page) {
|
|
@@ -270,12 +273,22 @@ export class C2S {
|
|
|
270
273
|
return this.sendInbox(res, url);
|
|
271
274
|
}
|
|
272
275
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
276
|
+
// The owner, signed in, reads every message the actor produced (§5.1:
|
|
277
|
+
// the outbox is filtered by who asks). Anyone else reads the public one.
|
|
278
|
+
const owner = pathname === '/ap/outbox' && req.headers.authorization
|
|
279
|
+
? (await this.auth(req, pathname)).ok : false;
|
|
273
280
|
// Where the client surface answers on an address a stranger can reach,
|
|
274
281
|
// the pod's copies already name it and are the documents. Standalone
|
|
275
282
|
// they are not reachable by the client asking, so it is answered here.
|
|
276
283
|
const local = this.agent.publisher?.clientOrigin ? null : this.localOrigin(req);
|
|
277
284
|
if (local && pathname === '/ap/actor') return this.sendLocalActor(req, res, local);
|
|
278
|
-
if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local);
|
|
285
|
+
if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local, { owner });
|
|
286
|
+
// The owner is answered here, not sent on: a redirect loses the
|
|
287
|
+
// credential (a browser drops it across origins, and a DPoP proof names
|
|
288
|
+
// the address it was made for), so the pod would refuse them.
|
|
289
|
+
if (owner && pathname === '/ap/outbox') {
|
|
290
|
+
return this.sendLocalOutbox(res, url, this.agent.publisher.clientOrigin, { owner });
|
|
291
|
+
}
|
|
279
292
|
// The pod's copy is the document; send the reader there.
|
|
280
293
|
const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
|
|
281
294
|
res.writeHead(303, { location: target, 'cache-control': 'no-store' });
|
|
@@ -334,14 +347,22 @@ export class C2S {
|
|
|
334
347
|
visibilityOf(activity, object) {
|
|
335
348
|
const to = arr(activity.to ?? object?.to).map(idOf);
|
|
336
349
|
const cc = arr(activity.cc ?? object?.cc).map(idOf);
|
|
337
|
-
|
|
350
|
+
// Blind copies alone are still an audience: a post for them and nobody
|
|
351
|
+
// else is direct, never public.
|
|
352
|
+
const blind = arr(activity.bto ?? object?.bto).length + arr(activity.bcc ?? object?.bcc).length;
|
|
353
|
+
if (!to.length && !cc.length) return blind ? 'direct' : 'public';
|
|
338
354
|
if (to.includes(wire.PUBLIC)) return 'public';
|
|
339
355
|
if (cc.includes(wire.PUBLIC)) return 'unlisted';
|
|
340
|
-
|
|
356
|
+
// Followers named anywhere — "to alice, cc my followers" included — is a
|
|
357
|
+
// followers-only post that also reaches whoever else is named.
|
|
358
|
+
if (to.includes(this.urls.followers) || cc.includes(this.urls.followers)) return 'private';
|
|
341
359
|
return 'direct';
|
|
342
360
|
}
|
|
343
361
|
|
|
344
|
-
|
|
362
|
+
// `serial` and `at` name what this activity will make: the outbox door
|
|
363
|
+
// chose them and told the client, so they are used as given. A client
|
|
364
|
+
// posting here directly gets fresh ones.
|
|
365
|
+
async dispatch(activity, { slug = null, raw = null, serial = Date.now(), at = new Date().toISOString() } = {}) {
|
|
345
366
|
const reply = (status, body, headers = {}) => ({ status, body, headers });
|
|
346
367
|
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
347
368
|
return reply(400, { error: 'a typed ActivityStreams object is required' });
|
|
@@ -349,10 +370,12 @@ export class C2S {
|
|
|
349
370
|
// A bare object arrives without an activity around it; the server supplies
|
|
350
371
|
// the Create (§6.2.1), carrying the object's own addressing up onto it.
|
|
351
372
|
if (!ACTIVITY_TYPES.has(activity.type)) {
|
|
352
|
-
|
|
373
|
+
// Every audience field is copied up onto the Create (§6.2.1).
|
|
374
|
+
const { to, cc, bto, bcc, audience } = activity;
|
|
375
|
+
activity = { type: 'Create', object: activity, to, cc, bto, bcc, audience };
|
|
353
376
|
}
|
|
354
377
|
try {
|
|
355
|
-
return await this._dispatch(activity, { slug, raw, reply });
|
|
378
|
+
return await this._dispatch(activity, { slug, raw, reply, serial, at });
|
|
356
379
|
} catch (e) {
|
|
357
380
|
this.log(`c2s ${activity?.type}: ${e.message}`);
|
|
358
381
|
return reply(422, { error: e.message || String(e) });
|
|
@@ -366,11 +389,23 @@ export class C2S {
|
|
|
366
389
|
const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
|
|
367
390
|
.filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
|
|
368
391
|
&& a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
|
|
369
|
-
|
|
392
|
+
// `audience` is who the post is for, a group most often: delivered to and
|
|
393
|
+
// named like to and cc (§7.1).
|
|
394
|
+
return { also: pick('to', 'cc', 'audience'), deliverTo: pick('bto', 'bcc') };
|
|
370
395
|
}
|
|
371
396
|
|
|
372
|
-
async _dispatch(activity, { slug, raw, reply }) {
|
|
397
|
+
async _dispatch(activity, { slug, raw, reply, serial, at }) {
|
|
373
398
|
const agent = this.agent;
|
|
399
|
+
// §6: every post to the outbox answers 201 with the new activity's id.
|
|
400
|
+
const made = (id, body = {}) => reply(201, { id, ...body }, { location: id });
|
|
401
|
+
// What is never sent anywhere — a person's block, a pin — is still a
|
|
402
|
+
// message this actor produced, so the owner's outbox records it here.
|
|
403
|
+
const kept = (type, extra) => {
|
|
404
|
+
const id = type === 'Undo' ? wire.undoActivityId(this.urls, serial) : `${this.urls.actor}#${type.toLowerCase()}-${serial}`;
|
|
405
|
+
const act = { id, type, actor: this.urls.actor, published: at, ...extra };
|
|
406
|
+
agent.publisher.recordOwn?.(act);
|
|
407
|
+
return act.id;
|
|
408
|
+
};
|
|
374
409
|
const object = typeof activity.object === 'object' && activity.object !== null
|
|
375
410
|
? activity.object : null;
|
|
376
411
|
const objectId = idOf(activity.object);
|
|
@@ -411,6 +446,7 @@ export class C2S {
|
|
|
411
446
|
visibility,
|
|
412
447
|
spoilerText: object.summary || null,
|
|
413
448
|
sensitive: object.sensitive === true,
|
|
449
|
+
slug,
|
|
414
450
|
});
|
|
415
451
|
return reply(201,
|
|
416
452
|
{ id: wire.createActivityId(question.id), object: question.id },
|
|
@@ -445,19 +481,32 @@ export class C2S {
|
|
|
445
481
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
446
482
|
return reply(403, { error: 'not your note' });
|
|
447
483
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
484
|
+
// §6.3: a client's Update changes only what it sends, and a field sent
|
|
485
|
+
// as null is removed. What was sent is read from the bytes, because a
|
|
486
|
+
// JSON-LD reading drops the nulls that mean "remove this".
|
|
487
|
+
const sent = raw?.type === 'Update' && raw.object && typeof raw.object === 'object' ? raw.object : (object || {});
|
|
488
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(sent, k);
|
|
489
|
+
const stored = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
|
|
490
|
+
if (stored?.type && stored.type !== 'Note') {
|
|
491
|
+
await agent.publisher.updateObject(s, sent, { updated: at });
|
|
492
|
+
return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
|
|
493
|
+
}
|
|
494
|
+
const text = has('content') || sent.source?.content !== undefined
|
|
495
|
+
? String(object?.source?.content ?? object?.content ?? '') : String(s.text ?? '');
|
|
496
|
+
if (!text.trim()) return reply(422, { error: 'the edit leaves the note with no content' });
|
|
497
|
+
const attachments = has('attachment')
|
|
451
498
|
? arr(object.attachment).map((a) => ({
|
|
452
499
|
url: a?.url, mediaType: a?.mediaType,
|
|
453
500
|
...(a?.name ? { description: a.name } : {}),
|
|
454
501
|
})).filter((a) => a.url)
|
|
455
502
|
: null;
|
|
456
503
|
await agent.publisher.updateNote(s, {
|
|
457
|
-
content: text,
|
|
458
|
-
|
|
504
|
+
content: text,
|
|
505
|
+
spoilerText: has('summary') ? (object?.summary || null) : (s.spoiler || null),
|
|
506
|
+
sensitive: has('sensitive') ? object?.sensitive === true : null, attachments,
|
|
507
|
+
updated: at,
|
|
459
508
|
});
|
|
460
|
-
return
|
|
509
|
+
return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
|
|
461
510
|
}
|
|
462
511
|
|
|
463
512
|
case 'Delete': {
|
|
@@ -471,7 +520,7 @@ export class C2S {
|
|
|
471
520
|
}
|
|
472
521
|
const r = await social.deleteNote(agent, s);
|
|
473
522
|
if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
|
|
474
|
-
return
|
|
523
|
+
return made(wire.deleteActivityId(s.noteId), { object: s.noteId });
|
|
475
524
|
}
|
|
476
525
|
|
|
477
526
|
case 'Follow': {
|
|
@@ -479,31 +528,27 @@ export class C2S {
|
|
|
479
528
|
// An acct: form or bare handle resolves through WebFinger; an https
|
|
480
529
|
// IRI is fetched directly.
|
|
481
530
|
if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
|
|
482
|
-
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
|
|
531
|
+
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''), { serial });
|
|
483
532
|
const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
|
|
484
|
-
return
|
|
485
|
-
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
533
|
+
return made(rec?.followActivity?.id, { object: r.actor });
|
|
486
534
|
}
|
|
487
|
-
const doc = await social.followActor(agent, objectId);
|
|
535
|
+
const doc = await social.followActor(agent, objectId, { serial });
|
|
488
536
|
const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
|
|
489
|
-
return
|
|
490
|
-
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
537
|
+
return made(rec?.followActivity?.id, { object: doc.id });
|
|
491
538
|
}
|
|
492
539
|
|
|
493
540
|
case 'Like': {
|
|
494
541
|
const s = this.byIri(objectId);
|
|
495
542
|
if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
|
|
496
|
-
const updated = await social.favourite(agent, s);
|
|
497
|
-
return
|
|
498
|
-
updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
|
|
543
|
+
const updated = await social.favourite(agent, s, { serial });
|
|
544
|
+
return made(updated.likeActivity?.id, { object: s.noteId });
|
|
499
545
|
}
|
|
500
546
|
|
|
501
547
|
case 'Announce': {
|
|
502
548
|
const s = this.byIri(objectId);
|
|
503
549
|
if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
|
|
504
|
-
const updated = await social.reblog(agent, s);
|
|
505
|
-
return
|
|
506
|
-
updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
|
|
550
|
+
const updated = await social.reblog(agent, s, { serial });
|
|
551
|
+
return made(updated.announceActivity?.id, { object: s.noteId });
|
|
507
552
|
}
|
|
508
553
|
|
|
509
554
|
case 'Undo': {
|
|
@@ -511,39 +556,47 @@ export class C2S {
|
|
|
511
556
|
// sends one, by its type+object when it re-states it instead.
|
|
512
557
|
const inner = object;
|
|
513
558
|
const innerId = idOf(activity.object);
|
|
559
|
+
const undone = wire.undoActivityId(this.urls, serial);
|
|
514
560
|
if (inner?.type === 'Block') {
|
|
515
561
|
const target = idOf(inner.object);
|
|
516
562
|
if (!target) return reply(400, { error: 'unblock whom?' });
|
|
517
|
-
await social.unblockActor(agent, target);
|
|
518
|
-
|
|
563
|
+
await social.unblockActor(agent, target, { serial });
|
|
564
|
+
// A group's unban was sent, and recorded as it went; a person's is not.
|
|
565
|
+
if (this.store.getConfig()?.kind !== 'group') {
|
|
566
|
+
kept('Undo', { object: { type: 'Block', actor: this.urls.actor, object: target } });
|
|
567
|
+
}
|
|
568
|
+
return made(undone, { object: target });
|
|
519
569
|
}
|
|
520
570
|
const statuses = this.store.getStatuses();
|
|
521
571
|
let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
|
|
522
572
|
if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
|
|
523
573
|
if (s?.favourited) {
|
|
524
|
-
const updated = await social.unfavourite(agent, s);
|
|
525
|
-
return
|
|
574
|
+
const updated = await social.unfavourite(agent, s, { serial });
|
|
575
|
+
return made(undone, { object: updated.noteId });
|
|
526
576
|
}
|
|
527
577
|
s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
|
|
528
578
|
if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
|
|
529
579
|
if (s?.reblogged) {
|
|
530
|
-
const updated = await social.unreblog(agent, s);
|
|
531
|
-
return
|
|
580
|
+
const updated = await social.unreblog(agent, s, { serial });
|
|
581
|
+
return made(undone, { object: updated.noteId });
|
|
532
582
|
}
|
|
533
583
|
const following = this.store.getContacts().following;
|
|
534
584
|
const rec = following.find((f) => f.followActivity?.id === innerId)
|
|
535
585
|
|| (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
|
|
536
586
|
if (rec) {
|
|
537
|
-
await social.unfollowActor(agent, rec.actor);
|
|
538
|
-
return
|
|
587
|
+
await social.unfollowActor(agent, rec.actor, { serial });
|
|
588
|
+
return made(undone, { object: rec.actor });
|
|
539
589
|
}
|
|
540
590
|
return reply(422, { error: 'nothing here matches what that Undo names' });
|
|
541
591
|
}
|
|
542
592
|
|
|
543
593
|
case 'Block': {
|
|
544
594
|
if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
|
|
545
|
-
await social.blockActor(agent, objectId);
|
|
546
|
-
|
|
595
|
+
await social.blockActor(agent, objectId, { serial });
|
|
596
|
+
// A group's ban was sent to its members; a person's never goes anywhere.
|
|
597
|
+
const id = this.store.getConfig()?.kind === 'group'
|
|
598
|
+
? `${this.urls.actor}#block-${serial}` : kept('Block', { object: objectId });
|
|
599
|
+
return made(id, { object: objectId });
|
|
547
600
|
}
|
|
548
601
|
|
|
549
602
|
case 'Add':
|
|
@@ -556,7 +609,8 @@ export class C2S {
|
|
|
556
609
|
const s = this.byIri(objectId);
|
|
557
610
|
if (!s) return reply(404, { error: 'no such note here' });
|
|
558
611
|
const updated = await social.pinStatus(agent, s, activity.type === 'Add');
|
|
559
|
-
return
|
|
612
|
+
return made(kept(activity.type, { object: updated.noteId, target: this.urls.featured }),
|
|
613
|
+
{ object: updated.noteId, pinned: !!updated.pinned });
|
|
560
614
|
}
|
|
561
615
|
|
|
562
616
|
case 'Accept':
|
|
@@ -566,10 +620,10 @@ export class C2S {
|
|
|
566
620
|
const requester = object?.actor ? idOf(object.actor) : objectId;
|
|
567
621
|
if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
|
|
568
622
|
const r = activity.type === 'Accept'
|
|
569
|
-
? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
|
|
570
|
-
: await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
|
|
623
|
+
? await social.admitRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }))
|
|
624
|
+
: await social.refuseRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }));
|
|
571
625
|
if (r.error) return reply(404, { error: r.error });
|
|
572
|
-
return
|
|
626
|
+
return made(`${this.urls.actor}#${activity.type.toLowerCase()}-${serial}`, { object: requester });
|
|
573
627
|
}
|
|
574
628
|
|
|
575
629
|
case 'Move':
|
|
@@ -255,6 +255,7 @@ export async function handle(api, ctx) {
|
|
|
255
255
|
if (req.method === 'POST' && !b.domains.includes(domain)) b.domains.push(domain);
|
|
256
256
|
if (req.method === 'DELETE') b.domains = b.domains.filter(d => d !== domain);
|
|
257
257
|
api.store.setBlocklist(b);
|
|
258
|
+
Promise.resolve(api.agent?.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
258
259
|
return send(200, {});
|
|
259
260
|
}
|
|
260
261
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// bridge.mjs — a fetch Request handed to code written for Node's request and
|
|
2
|
+
// response: the Mastodon facade and the admin surface. The browser worker
|
|
3
|
+
// answers its own client through it (web/app/sw-src.mjs), and the gateway
|
|
4
|
+
// answers Mastodon apps through it (lib/gateway/masto-gateway.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Returns { req, res, bodyBytes, bodyText, response() }: `response()` is what
|
|
7
|
+
// the handler wrote, as a fetch Response.
|
|
8
|
+
export async function bridge(request, url, { sameOrigin = false } = {}) {
|
|
9
|
+
// Bytes, not text. A multipart upload is binary — read as text it comes back
|
|
10
|
+
// through a UTF-8 round trip that replaces every byte that is not valid UTF-8,
|
|
11
|
+
// which is most of a JPEG, so the boundary search found nothing and every
|
|
12
|
+
// media and avatar upload answered "422 file required". readBody() does
|
|
13
|
+
// `data += chunk`, which decodes a Buffer the same way it always did, so the
|
|
14
|
+
// JSON and form paths are unchanged.
|
|
15
|
+
const bodyBytes = (request.method === 'GET' || request.method === 'HEAD')
|
|
16
|
+
? null : Buffer.from(new Uint8Array(await request.arrayBuffer()));
|
|
17
|
+
const bodyText = bodyBytes ? new TextDecoder().decode(bodyBytes) : '';
|
|
18
|
+
const reqHeaders = {}; for (const [k, v] of request.headers) reqHeaders[k.toLowerCase()] = v;
|
|
19
|
+
// `host` is a forbidden header name, so a fetch Request never carries one and
|
|
20
|
+
// the loop above cannot produce it — but it is a header every real request
|
|
21
|
+
// arrives with, and the agent reads it to say where it lives. Without it the
|
|
22
|
+
// notifications `Link` header named `https://undefined/`, so a client that
|
|
23
|
+
// paged by following it (which is how a client is meant to page) walked off
|
|
24
|
+
// the origin and saw nothing past the first screen.
|
|
25
|
+
reqHeaders.host = url.host;
|
|
26
|
+
const listeners = {};
|
|
27
|
+
// The body's events fire on the next microtask. A route that reads the body
|
|
28
|
+
// may only register for them after an await or two — the facade dispatches
|
|
29
|
+
// through its area modules first — so a listener that arrives after the
|
|
30
|
+
// events have fired is given them at once, in the order it asks.
|
|
31
|
+
let fired = false;
|
|
32
|
+
const req = { method: request.method, url: url.pathname + url.search, headers: reqHeaders,
|
|
33
|
+
// Whether the caller is a page on the agent's own origin. The browser
|
|
34
|
+
// worker has established that before anything reaches the facade, and
|
|
35
|
+
// MastoApi asks (through its authorities) whether a request is the owner's
|
|
36
|
+
// own; a request at the gateway never is.
|
|
37
|
+
sameOrigin,
|
|
38
|
+
socket: { encrypted: url.protocol === 'https:' },
|
|
39
|
+
on(ev, cb) {
|
|
40
|
+
(listeners[ev] ||= []).push(cb);
|
|
41
|
+
if (fired) { if (ev === 'data' && bodyBytes?.length) cb(bodyBytes); else if (ev === 'end') cb(); }
|
|
42
|
+
return req;
|
|
43
|
+
},
|
|
44
|
+
destroy() {} };
|
|
45
|
+
queueMicrotask(() => {
|
|
46
|
+
fired = true;
|
|
47
|
+
if (bodyBytes?.length) (listeners.data || []).forEach((cb) => cb(bodyBytes));
|
|
48
|
+
(listeners.end || []).forEach((cb) => cb());
|
|
49
|
+
});
|
|
50
|
+
let status = 200; const outHeaders = {}; const chunks = [];
|
|
51
|
+
const res = {
|
|
52
|
+
writeHead(s, h) { status = s; if (h) Object.assign(outHeaders, h); return res; },
|
|
53
|
+
setHeader(k, v) { outHeaders[k] = v; }, getHeader(k) { return outHeaders[k]; },
|
|
54
|
+
write(c) { chunks.push(c); }, end(c) { if (c) chunks.push(c); },
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
req, res, bodyBytes, bodyText,
|
|
58
|
+
get status() { return status; },
|
|
59
|
+
response: () => new Response(chunks.join(''), { status, headers: { 'content-type': 'application/json', ...outHeaders } }),
|
|
60
|
+
};
|
|
61
|
+
}
|