fedipod 1.6.0 → 1.8.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 +9 -3
- package/device-agent.md +99 -0
- package/gateway.md +16 -0
- package/groups.md +1 -1
- package/gui.md +1 -1
- package/lib/client/c2s.mjs +95 -61
- package/lib/client/masto/index.mjs +7 -1
- package/lib/client/masto/timelines.mjs +1 -1
- package/lib/client/oidc-auth.mjs +5 -3
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/intake/index.mjs +32 -5
- package/lib/core/publisher/index.mjs +15 -1
- package/lib/core/publisher/notes.mjs +84 -2
- package/lib/core/publisher/questions.mjs +1 -0
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +4 -0
- package/lib/core/wire.mjs +20 -13
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +9 -1
- package/lib/device/setup.mjs +6 -0
- package/lib/gateway/front-core.mjs +66 -12
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/root.mjs +11 -0
- package/lib/pod/transport.mjs +9 -3
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -2
- package/run-agent.mjs +10 -1
- package/scripts/refresh-contexts.mjs +5 -0
- package/vendor/gate.cjs +5 -2
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +11 -1
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +89 -37
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +1326 -662
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/signup.mjs +3 -1
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1326 -662
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
package/README.md
CHANGED
|
@@ -9,15 +9,14 @@ one timeline. Your posts, followers and settings stay on your pod.
|
|
|
9
9
|
The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. A wizard will walk you through getting a pod (you can also bring your own) and creating a Fediverse identity attached to the pod.
|
|
10
10
|
|
|
11
11
|
There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipod) which offer a variety of scenarios. If interested in the code, see also : [architecture overview](architecture.md) and [files overview](files-overview.md).
|
|
12
|
+
Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
|
|
12
13
|
|
|
13
14
|
## Requirements
|
|
14
15
|
|
|
15
16
|
- A current browser, on a desktop or a phone.
|
|
16
17
|
- A Solid pod with a host name of its own, such as
|
|
17
18
|
`https://alice.solidcommunity.net/`. Sign-up can create one for you at
|
|
18
|
-
solidcommunity.net or another provider, or use a pod you already have.
|
|
19
|
-
pod that lives on a suffix-based host, like `https://server.example/alice/`,
|
|
20
|
-
cannot be a Fediverse address.
|
|
19
|
+
solidcommunity.net or another provider, or use a pod you already have.
|
|
21
20
|
A pod on a suffix-based host, like `https://server.example/alice/`,
|
|
22
21
|
works too. Its address is then `@handle@fedipod.net`, because the shared
|
|
23
22
|
host cannot answer for the handle; your posts, key and data stay on your pod.
|
|
@@ -41,6 +40,8 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
|
|
|
41
40
|
Your account then opens in the client. To use it from another browser, go to
|
|
42
41
|
https://fedipod.net, enter your address, sign in at your pod, and unlock your
|
|
43
42
|
key with your password once on that browser.
|
|
43
|
+
If you have changed your pod password since you signed up, choose "make a new
|
|
44
|
+
signing key" on that screen and use the password you use now.
|
|
44
45
|
|
|
45
46
|
## What you can do
|
|
46
47
|
|
|
@@ -70,6 +71,11 @@ hands back stays in your browser unless you choose to keep it on your pod.
|
|
|
70
71
|
domain blocks import from the manage page. Your old account can be listed as
|
|
71
72
|
an alias, so a Move from it lands here.
|
|
72
73
|
|
|
74
|
+
**Posting from another app.** Any app that speaks ActivityPub
|
|
75
|
+
client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
76
|
+
address in your actor document, which your WebID profile also names, signed in
|
|
77
|
+
at your pod. The post goes out the next time you open fedipod.net.
|
|
78
|
+
|
|
73
79
|
**The manage page.** `manage account` in the bar opens it: your profile,
|
|
74
80
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
75
81
|
another server, retiring, and clearing a backlog. It is the same interface
|
package/device-agent.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# The DeviceAgent
|
|
2
|
+
|
|
3
|
+
FediPod can also run as a program on your own machine, in front of the same
|
|
4
|
+
kind of pod. It does everything the browser version at fedipod.net does, plus
|
|
5
|
+
what a browser tab cannot: it keeps running while no tab is open, so scheduled
|
|
6
|
+
posts go out and push notifications reach you; it serves the Mastodon streaming
|
|
7
|
+
API, so clients update live; any Mastodon client, phone app or desktop, can
|
|
8
|
+
connect to it; and it can host a [group](groups.md).
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node 20 or newer.
|
|
13
|
+
- A Solid pod, either with a host name of its own, such as
|
|
14
|
+
`https://alice.solidcommunity.net/`, or on a suffix-based host, such as
|
|
15
|
+
`https://server.example/alice/`. A pod at its own host carries its address on
|
|
16
|
+
the pod; a pod on a suffix-based host takes its address at a gateway,
|
|
17
|
+
`@handle@fedipod.net`, with the posts, key and data staying on the pod.
|
|
18
|
+
- Followers-only and direct posts need a pod that enforces WAC access control;
|
|
19
|
+
on one that does not, the composer refuses those two and says why.
|
|
20
|
+
- While the agent is off, your mail waits on your pod's host. Run it as a
|
|
21
|
+
service, or attach to a gateway, so it does not pile up there.
|
|
22
|
+
|
|
23
|
+
## Installing
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npm install -g fedipod
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Running
|
|
30
|
+
|
|
31
|
+
Run `fedipod start`. Add a port to change the local agent's port, for example
|
|
32
|
+
`fedipod start --port 8081`; the default is 8030. Then point any browser at
|
|
33
|
+
`https://localhost:8030`, or the port you chose, and the setup pages take it
|
|
34
|
+
from there.
|
|
35
|
+
|
|
36
|
+
## Running as a service
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
fedipod install-service
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It registers every identity on this machine, one service each, so all of your
|
|
43
|
+
actors start at boot. An identity running in a terminal is stopped and taken
|
|
44
|
+
over by its service. `fedipod uninstall-service` reverses it.
|
|
45
|
+
|
|
46
|
+
## Managing
|
|
47
|
+
|
|
48
|
+
Posts, logs, parking, moving, transferring and the rest are on the
|
|
49
|
+
[admin interface](gui.md); starting, stopping and what a page cannot do are in
|
|
50
|
+
[CLI admin](cli.md). The admin tools also create other actors, groups or
|
|
51
|
+
persons. You may have as many as you want on one machine, each with a pod of
|
|
52
|
+
its own.
|
|
53
|
+
|
|
54
|
+
Every agent checks once a day whether a newer FediPod is published. When one
|
|
55
|
+
exists, the record page offers **Update**, and `fedipod update` does the same
|
|
56
|
+
from the terminal. `AP_UPDATE_CHECK=0` turns the check off.
|
|
57
|
+
|
|
58
|
+
## A gateway account
|
|
59
|
+
|
|
60
|
+
Most of what a Fediverse inbox receives is broadcast noise. A
|
|
61
|
+
[gateway](gateway.md) is a shared, always-on door that verifies each delivery,
|
|
62
|
+
drops the junk, and passes the rest to your pod, while your key and data stay
|
|
63
|
+
on your pod. There is a free one at [fedipod.net](https://fedipod.net/).
|
|
64
|
+
Attaching or detaching is a few wizard-guided clicks from your agent, and it
|
|
65
|
+
takes the mail load off your pod's host.
|
|
66
|
+
|
|
67
|
+
## Clients
|
|
68
|
+
|
|
69
|
+
The bundled client is [Phanpy](https://github.com/cheeaun/phanpy) (MIT, by
|
|
70
|
+
Chee Aun), served by the agent itself, and logging in is one click. If you
|
|
71
|
+
ever enter the instance by hand, use the address on the record's **local
|
|
72
|
+
host** row.
|
|
73
|
+
|
|
74
|
+
- **Other web clients**: drop any static Mastodon client dist into
|
|
75
|
+
`ui/<name>/` and it is served at `/<name>/`; see `ui/README.md`.
|
|
76
|
+
- **Desktop and phone clients** (Tuba, Whalebird, and the like): add
|
|
77
|
+
`https://localhost:8030`, or your agent's port, as a custom instance.
|
|
78
|
+
- **Streaming**: the agent serves the Mastodon streaming API at
|
|
79
|
+
`/api/v1/streaming`, so clients update live instead of polling.
|
|
80
|
+
- **Web push**: notifications reach you while the client is closed.
|
|
81
|
+
- **Scheduled posts** go out at the time you picked.
|
|
82
|
+
|
|
83
|
+
Polls, content warnings, editing, all four visibility levels, direct
|
|
84
|
+
messages, bookmarks, favourites, lists, keyword filters, pinned posts,
|
|
85
|
+
blocking and muting, and custom emojis work as in the browser version.
|
|
86
|
+
|
|
87
|
+
## Bluesky, and your other Fediverse accounts
|
|
88
|
+
|
|
89
|
+
A Bluesky connection lets the agent drive an existing Bluesky, or other
|
|
90
|
+
ATProto, account alongside your Fediverse identity: public posts are
|
|
91
|
+
cross-posted as a mirror, with a toggle to turn it off; Bluesky replies and
|
|
92
|
+
activity flow into your timeline; you can like, boost and reply to Bluesky
|
|
93
|
+
posts. Direct messages to Bluesky are not supported.
|
|
94
|
+
|
|
95
|
+
An account on Mastodon or any server speaking the Mastodon API can be
|
|
96
|
+
connected from the **Other identities** row of the admin page. Its home
|
|
97
|
+
timeline and notifications join your feed, a post both accounts see appears
|
|
98
|
+
once, and favouriting, boosting and replying act as the account the post came
|
|
99
|
+
through. The token it hands back stays on this machine.
|
package/gateway.md
CHANGED
|
@@ -10,6 +10,9 @@ Your name, your signing key and your data stay 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
|
+
With the outbox door below, a gateway can also hand your agent a post marked as
|
|
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.
|
|
13
16
|
|
|
14
17
|
A FediPod install works without any gateway at all. Deliveries go straight to
|
|
15
18
|
your pod inbox, which holds them whether your agent is running or not.
|
|
@@ -102,6 +105,19 @@ yourself. When you attach to a multi-user gateway, the direction is reversed:
|
|
|
102
105
|
the gateway mints the secret and answers the attach with it, and your agent
|
|
103
106
|
records it.
|
|
104
107
|
|
|
108
|
+
## The outbox door
|
|
109
|
+
|
|
110
|
+
The gateway also takes your own posts from any app that speaks ActivityPub
|
|
111
|
+
client-to-server, dokieli for one. Your actor document names the door as your
|
|
112
|
+
outbox, and your WebID profile names it as `as:outbox`. The app sends the post
|
|
113
|
+
there, signed in at your pod. The door checks that the token is yours, puts the
|
|
114
|
+
post in your inbox marked as yours, and answers with the address the post will
|
|
115
|
+
have. Your agent publishes it and sends it to your followers the next time it
|
|
116
|
+
runs: for a browser account, the next time you open the site.
|
|
117
|
+
|
|
118
|
+
A post with no audience of its own goes out as a public post. A post that is
|
|
119
|
+
not a note, an annotation say, is kept as the app sent it, under your name.
|
|
120
|
+
|
|
105
121
|
## What the gateway can see
|
|
106
122
|
|
|
107
123
|
It reads only public data to decide what concerns you: your published
|
package/groups.md
CHANGED
|
@@ -59,7 +59,7 @@ A followed group's own announced deletion of a post it carried to you is honoure
|
|
|
59
59
|
## Inviting people
|
|
60
60
|
|
|
61
61
|
A group has a page anyone can open, at `ap/profile.html` under its pod's
|
|
62
|
-
app container — `<pod>/
|
|
62
|
+
app container — `<pod>/fedipod/ap/profile.html`. It
|
|
63
63
|
carries the group's address and a Follow box that sends a visitor to their
|
|
64
64
|
own server's follow screen, so it is the link to put where people will find
|
|
65
65
|
it. Posts the group carries appear in members' timelines as the group
|
package/gui.md
CHANGED
|
@@ -46,7 +46,7 @@ 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/
|
|
49
|
+
— for example `https://your-pod.example/fedipod/ap/profile.html`. It
|
|
50
50
|
shows the name, bio and address, and offers a Follow box: a visitor types
|
|
51
51
|
their own server and lands on that server's follow screen. Hand out that
|
|
52
52
|
link, or the `@name@host` address itself, which works in the search box of
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// GETs on the actor and outbox are redirects: the pod's documents are the
|
|
9
9
|
// canonical ones, and a second renderer here would only drift from them.
|
|
10
10
|
//
|
|
11
|
+
// `dispatch` takes an activity and answers with { status, body, headers }; it
|
|
12
|
+
// touches no request or response. The HTTP handler below is one caller. The
|
|
13
|
+
// other is the inbox drain, handing over an activity the Gateway took at the
|
|
14
|
+
// outbox door on the owner's behalf and stamped as theirs.
|
|
15
|
+
//
|
|
11
16
|
// The inbox is the exception, and has to be. Deliveries land in a container on
|
|
12
17
|
// the pod which the drain empties as it handles each item, so reading that
|
|
13
18
|
// container tells the owner only what has not been dealt with yet. What was
|
|
@@ -18,6 +23,7 @@
|
|
|
18
23
|
import * as social from '../core/social.mjs';
|
|
19
24
|
import * as wire from '../core/wire.mjs';
|
|
20
25
|
import { readLenient } from '../core/as2.mjs';
|
|
26
|
+
import { safeSlug } from '../core/publisher/notes.mjs';
|
|
21
27
|
|
|
22
28
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
23
29
|
|
|
@@ -154,7 +160,10 @@ export class C2S {
|
|
|
154
160
|
async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
|
|
155
161
|
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
|
|
156
162
|
if (req.method === 'OPTIONS') {
|
|
157
|
-
|
|
163
|
+
// Accept-Post is what a client such as dokieli reads to choose a format;
|
|
164
|
+
// naming JSON only is what makes it send JSON-LD rather than HTML.
|
|
165
|
+
res.writeHead(204, pathname === '/ap/inbox' ? { allow: 'GET, OPTIONS' }
|
|
166
|
+
: { allow: 'GET, POST, OPTIONS', 'accept-post': 'application/ld+json, application/activity+json' });
|
|
158
167
|
res.end(); return true;
|
|
159
168
|
}
|
|
160
169
|
if (!this.agent.configured() || !this.urls) {
|
|
@@ -196,9 +205,16 @@ export class C2S {
|
|
|
196
205
|
// takes decisions from it and the publisher builds the document that is
|
|
197
206
|
// actually posted, so nothing a client sent is republished verbatim and a
|
|
198
207
|
// term it aliased still means what it says.
|
|
199
|
-
|
|
208
|
+
// `raw` is the document as the client wrote it. The graph is what
|
|
209
|
+
// decisions are taken from; the bytes are what an object that is stored
|
|
210
|
+
// as sent (publishObject) is stored from — a read against our contexts
|
|
211
|
+
// rewrites a stranger's terms to full IRIs, which is right for reading and
|
|
212
|
+
// wrong for keeping.
|
|
213
|
+
let activity; let raw = null;
|
|
200
214
|
try {
|
|
201
|
-
const
|
|
215
|
+
const body = await readBody(req);
|
|
216
|
+
try { raw = JSON.parse(body); } catch { raw = null; }
|
|
217
|
+
const read = await readLenient(body);
|
|
202
218
|
activity = read.view ?? read.doc;
|
|
203
219
|
} catch (e) {
|
|
204
220
|
return this.send(res, 400, { error: `unreadable body: ${e.message}` });
|
|
@@ -206,34 +222,47 @@ export class C2S {
|
|
|
206
222
|
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
207
223
|
return this.send(res, 400, { error: 'a typed ActivityStreams object is required' });
|
|
208
224
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
return await this.dispatch(res, activity);
|
|
217
|
-
} catch (e) {
|
|
218
|
-
this.log(`c2s ${activity.type}: ${e.message}`);
|
|
219
|
-
return this.send(res, 422, { error: e.message || String(e) });
|
|
220
|
-
}
|
|
225
|
+
// The name the client asks for its new document (LDP's Slug), taken when
|
|
226
|
+
// it is plain and free — it is what lets the client know the address of
|
|
227
|
+
// what it made before anything answers.
|
|
228
|
+
const slug = safeSlug(req.headers.slug) || null;
|
|
229
|
+
const r = await this.dispatch(activity, { slug, raw });
|
|
230
|
+
return this.send(res, r.status, r.body, r.headers);
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
// Addressing → the facade's four visibilities, inverting the table the
|
|
224
|
-
// composer writes (wire.
|
|
225
|
-
//
|
|
234
|
+
// composer writes (wire.addressing). Nothing stated is a public post — what
|
|
235
|
+
// every client means by a post with no audience chosen, and what a client
|
|
236
|
+
// that never addresses (dokieli's annotations) needs.
|
|
226
237
|
visibilityOf(activity, object) {
|
|
227
238
|
const to = arr(activity.to ?? object?.to).map(idOf);
|
|
228
239
|
const cc = arr(activity.cc ?? object?.cc).map(idOf);
|
|
229
|
-
if (!to.length && !cc.length) return
|
|
240
|
+
if (!to.length && !cc.length) return 'public';
|
|
230
241
|
if (to.includes(wire.PUBLIC)) return 'public';
|
|
231
242
|
if (cc.includes(wire.PUBLIC)) return 'unlisted';
|
|
232
243
|
if (to.includes(this.urls.followers)) return 'private';
|
|
233
244
|
return 'direct';
|
|
234
245
|
}
|
|
235
246
|
|
|
236
|
-
async dispatch(
|
|
247
|
+
async dispatch(activity, { slug = null, raw = null } = {}) {
|
|
248
|
+
const reply = (status, body, headers = {}) => ({ status, body, headers });
|
|
249
|
+
if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
|
|
250
|
+
return reply(400, { error: 'a typed ActivityStreams object is required' });
|
|
251
|
+
}
|
|
252
|
+
// A bare object arrives without an activity around it; the server supplies
|
|
253
|
+
// the Create (§6.2.1), carrying the object's own addressing up onto it.
|
|
254
|
+
if (!ACTIVITY_TYPES.has(activity.type)) {
|
|
255
|
+
activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return await this._dispatch(activity, { slug, raw, reply });
|
|
259
|
+
} catch (e) {
|
|
260
|
+
this.log(`c2s ${activity?.type}: ${e.message}`);
|
|
261
|
+
return reply(422, { error: e.message || String(e) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _dispatch(activity, { slug, raw, reply }) {
|
|
237
266
|
const agent = this.agent;
|
|
238
267
|
const object = typeof activity.object === 'object' && activity.object !== null
|
|
239
268
|
? activity.object : null;
|
|
@@ -242,18 +271,22 @@ export class C2S {
|
|
|
242
271
|
switch (activity.type) {
|
|
243
272
|
case 'Create': {
|
|
244
273
|
const makes = object?.type || 'Note';
|
|
245
|
-
if (!object
|
|
246
|
-
return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
|
|
247
|
-
}
|
|
274
|
+
if (!object) return reply(422, { error: 'a Create carries the object it creates' });
|
|
248
275
|
const visibility = this.visibilityOf(activity, object);
|
|
249
|
-
|
|
250
|
-
|
|
276
|
+
// Not a Note and not a poll: stored as sent, under this actor, and
|
|
277
|
+
// the Create around it delivered — a Web Annotation, for one.
|
|
278
|
+
if (makes !== 'Note' && makes !== 'Question') {
|
|
279
|
+
const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
280
|
+
? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
|
|
281
|
+
: null;
|
|
282
|
+
const made = await agent.publisher.publishObject(asSent || object, { visibility, slug });
|
|
283
|
+
return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
|
|
251
284
|
}
|
|
252
285
|
// `source.content` is the client's plain text when it sends one; bare
|
|
253
286
|
// `content` is TREATED as plain text and escaped — markup survives as
|
|
254
287
|
// visible characters rather than as markup. Documented v1 limit.
|
|
255
288
|
const text = String(object.source?.content ?? object.content ?? '');
|
|
256
|
-
if (!text.trim()) return
|
|
289
|
+
if (!text.trim()) return reply(422, { error: 'the note has no content' });
|
|
257
290
|
|
|
258
291
|
// A Question is a poll: the choices are in oneOf (pick one) or anyOf
|
|
259
292
|
// (pick several), each naming itself, and endTime is when it shuts.
|
|
@@ -270,11 +303,11 @@ export class C2S {
|
|
|
270
303
|
visibility,
|
|
271
304
|
spoilerText: object.summary || null,
|
|
272
305
|
});
|
|
273
|
-
return
|
|
306
|
+
return reply(201,
|
|
274
307
|
{ id: wire.createActivityId(question.id), object: question.id },
|
|
275
308
|
{ location: wire.createActivityId(question.id) });
|
|
276
309
|
} catch (e) {
|
|
277
|
-
return
|
|
310
|
+
return reply(422, { error: e.message });
|
|
278
311
|
}
|
|
279
312
|
}
|
|
280
313
|
|
|
@@ -287,22 +320,23 @@ export class C2S {
|
|
|
287
320
|
attachments,
|
|
288
321
|
visibility,
|
|
289
322
|
spoilerText: object.summary || null,
|
|
323
|
+
slug,
|
|
290
324
|
});
|
|
291
|
-
return
|
|
325
|
+
return reply(201, { id: wire.createActivityId(note.id), object: note.id },
|
|
292
326
|
{ location: wire.createActivityId(note.id) });
|
|
293
327
|
}
|
|
294
328
|
|
|
295
329
|
case 'Update': {
|
|
296
330
|
if (objectId === this.urls.actor) {
|
|
297
|
-
return
|
|
331
|
+
return reply(422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
|
|
298
332
|
}
|
|
299
333
|
const s = this.byIri(objectId);
|
|
300
|
-
if (!s) return
|
|
334
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
301
335
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
302
|
-
return
|
|
336
|
+
return reply(403, { error: 'not your note' });
|
|
303
337
|
}
|
|
304
338
|
const text = String(object?.source?.content ?? object?.content ?? '');
|
|
305
|
-
if (!text.trim()) return
|
|
339
|
+
if (!text.trim()) return reply(422, { error: 'the edit has no content' });
|
|
306
340
|
const attachments = object?.attachment !== undefined
|
|
307
341
|
? arr(object.attachment).map((a) => ({
|
|
308
342
|
url: a?.url, mediaType: a?.mediaType,
|
|
@@ -312,52 +346,52 @@ export class C2S {
|
|
|
312
346
|
await agent.publisher.updateNote(s, {
|
|
313
347
|
content: text, spoilerText: object?.summary || null, attachments,
|
|
314
348
|
});
|
|
315
|
-
return
|
|
349
|
+
return reply(200, { ok: true, object: s.noteId });
|
|
316
350
|
}
|
|
317
351
|
|
|
318
352
|
case 'Delete': {
|
|
319
353
|
if (objectId === this.urls.actor) {
|
|
320
|
-
return
|
|
354
|
+
return reply(422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
|
|
321
355
|
}
|
|
322
356
|
const s = this.byIri(objectId);
|
|
323
|
-
if (!s) return
|
|
357
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
324
358
|
if (s.actor !== this.urls.actor || s.kind !== 'post') {
|
|
325
|
-
return
|
|
359
|
+
return reply(403, { error: 'not your note' });
|
|
326
360
|
}
|
|
327
361
|
const r = await social.deleteNote(agent, s);
|
|
328
|
-
if (!r.ok) return
|
|
329
|
-
return
|
|
362
|
+
if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
|
|
363
|
+
return reply(200, { ok: true });
|
|
330
364
|
}
|
|
331
365
|
|
|
332
366
|
case 'Follow': {
|
|
333
|
-
if (!objectId) return
|
|
367
|
+
if (!objectId) return reply(400, { error: 'whom? object must name an actor' });
|
|
334
368
|
// An acct: form or bare handle resolves through WebFinger; an https
|
|
335
369
|
// IRI is fetched directly.
|
|
336
370
|
if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
|
|
337
371
|
const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
|
|
338
372
|
const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
|
|
339
|
-
return
|
|
373
|
+
return reply(201, { id: rec?.followActivity?.id, object: r.actor },
|
|
340
374
|
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
341
375
|
}
|
|
342
376
|
const doc = await social.followActor(agent, objectId);
|
|
343
377
|
const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
|
|
344
|
-
return
|
|
378
|
+
return reply(201, { id: rec?.followActivity?.id, object: doc.id },
|
|
345
379
|
rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
|
|
346
380
|
}
|
|
347
381
|
|
|
348
382
|
case 'Like': {
|
|
349
383
|
const s = this.byIri(objectId);
|
|
350
|
-
if (!s) return
|
|
384
|
+
if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
|
|
351
385
|
const updated = await social.favourite(agent, s);
|
|
352
|
-
return
|
|
386
|
+
return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
|
|
353
387
|
updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
|
|
354
388
|
}
|
|
355
389
|
|
|
356
390
|
case 'Announce': {
|
|
357
391
|
const s = this.byIri(objectId);
|
|
358
|
-
if (!s) return
|
|
392
|
+
if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
|
|
359
393
|
const updated = await social.reblog(agent, s);
|
|
360
|
-
return
|
|
394
|
+
return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
|
|
361
395
|
updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
|
|
362
396
|
}
|
|
363
397
|
|
|
@@ -368,37 +402,37 @@ export class C2S {
|
|
|
368
402
|
const innerId = idOf(activity.object);
|
|
369
403
|
if (inner?.type === 'Block') {
|
|
370
404
|
const target = idOf(inner.object);
|
|
371
|
-
if (!target) return
|
|
405
|
+
if (!target) return reply(400, { error: 'unblock whom?' });
|
|
372
406
|
await social.unblockActor(agent, target);
|
|
373
|
-
return
|
|
407
|
+
return reply(200, { ok: true, object: target });
|
|
374
408
|
}
|
|
375
409
|
const statuses = this.store.getStatuses();
|
|
376
410
|
let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
|
|
377
411
|
if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
|
|
378
412
|
if (s?.favourited) {
|
|
379
413
|
const updated = await social.unfavourite(agent, s);
|
|
380
|
-
return
|
|
414
|
+
return reply(200, { ok: true, object: updated.noteId });
|
|
381
415
|
}
|
|
382
416
|
s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
|
|
383
417
|
if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
|
|
384
418
|
if (s?.reblogged) {
|
|
385
419
|
const updated = await social.unreblog(agent, s);
|
|
386
|
-
return
|
|
420
|
+
return reply(200, { ok: true, object: updated.noteId });
|
|
387
421
|
}
|
|
388
422
|
const following = this.store.getContacts().following;
|
|
389
423
|
const rec = following.find((f) => f.followActivity?.id === innerId)
|
|
390
424
|
|| (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
|
|
391
425
|
if (rec) {
|
|
392
426
|
await social.unfollowActor(agent, rec.actor);
|
|
393
|
-
return
|
|
427
|
+
return reply(200, { ok: true, object: rec.actor });
|
|
394
428
|
}
|
|
395
|
-
return
|
|
429
|
+
return reply(422, { error: 'nothing here matches what that Undo names' });
|
|
396
430
|
}
|
|
397
431
|
|
|
398
432
|
case 'Block': {
|
|
399
|
-
if (!objectId) return
|
|
433
|
+
if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
|
|
400
434
|
await social.blockActor(agent, objectId);
|
|
401
|
-
return
|
|
435
|
+
return reply(200, { ok: true, object: objectId });
|
|
402
436
|
}
|
|
403
437
|
|
|
404
438
|
case 'Add':
|
|
@@ -406,12 +440,12 @@ export class C2S {
|
|
|
406
440
|
// The one collection a client may edit is the pins (§7.6/§7.9 in the
|
|
407
441
|
// other direction): target must be the featured collection.
|
|
408
442
|
if (idOf(activity.target) !== this.urls.featured) {
|
|
409
|
-
return
|
|
443
|
+
return reply(422, { error: 'the featured collection is the one Add/Remove edits here' });
|
|
410
444
|
}
|
|
411
445
|
const s = this.byIri(objectId);
|
|
412
|
-
if (!s) return
|
|
446
|
+
if (!s) return reply(404, { error: 'no such note here' });
|
|
413
447
|
const updated = await social.pinStatus(agent, s, activity.type === 'Add');
|
|
414
|
-
return
|
|
448
|
+
return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
|
|
415
449
|
}
|
|
416
450
|
|
|
417
451
|
case 'Accept':
|
|
@@ -419,19 +453,19 @@ export class C2S {
|
|
|
419
453
|
// Answering a held follow request: the object is the Follow (or the
|
|
420
454
|
// requester). Which request is meant comes from the Follow's actor.
|
|
421
455
|
const requester = object?.actor ? idOf(object.actor) : objectId;
|
|
422
|
-
if (!requester) return
|
|
456
|
+
if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
|
|
423
457
|
const r = activity.type === 'Accept'
|
|
424
458
|
? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
|
|
425
459
|
: await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
|
|
426
|
-
if (r.error) return
|
|
427
|
-
return
|
|
460
|
+
if (r.error) return reply(404, { error: r.error });
|
|
461
|
+
return reply(200, { ok: true, object: requester });
|
|
428
462
|
}
|
|
429
463
|
|
|
430
464
|
case 'Move':
|
|
431
|
-
return
|
|
465
|
+
return reply(422, { error: 'moving the account is done on the admin surface, where it asks twice' });
|
|
432
466
|
|
|
433
467
|
default:
|
|
434
|
-
return
|
|
468
|
+
return reply(422, { error: `no handler for ${activity.type}` });
|
|
435
469
|
}
|
|
436
470
|
}
|
|
437
471
|
}
|
|
@@ -32,8 +32,14 @@ export { attachmentType, extensionFor } from './media.mjs';
|
|
|
32
32
|
|
|
33
33
|
export class MastoApi {
|
|
34
34
|
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
|
|
35
|
-
streaming = true, webPush = true, scheduling = true }) {
|
|
35
|
+
mount = '', streaming = true, webPush = true, scheduling = true }) {
|
|
36
36
|
this.agent = agent;
|
|
37
|
+
// The path this identity's surface answers under, when it shares its origin
|
|
38
|
+
// with others (a suffix pod, e.g. `/aisha`). Empty for a host-root or
|
|
39
|
+
// subdomain pod. Folded into the self-URLs the client is handed —
|
|
40
|
+
// pagination links, the OAuth issuer — so they name the address the client
|
|
41
|
+
// actually reached.
|
|
42
|
+
this.mount = mount;
|
|
37
43
|
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
38
44
|
// when it refuses has to name the route that identity really has.
|
|
39
45
|
this.embedded = embedded;
|
|
@@ -254,7 +254,7 @@ export async function handle(api, ctx) {
|
|
|
254
254
|
|
|
255
255
|
// A client pages by following these rather than by guessing ids.
|
|
256
256
|
if (page.length) {
|
|
257
|
-
const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
|
|
257
|
+
const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${api.mount || ''}${pathname}`;
|
|
258
258
|
const link = (params) => {
|
|
259
259
|
const u = new URL(base);
|
|
260
260
|
for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The verifier is injected so offline tests stub it, and wrapped so the
|
|
9
9
|
// library (CJS, older jose) can be replaced without touching any caller.
|
|
10
10
|
|
|
11
|
-
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null }) {
|
|
11
|
+
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
|
|
12
12
|
let verify = verifier;
|
|
13
13
|
const loadVerifier = async () => {
|
|
14
14
|
if (!verify) {
|
|
@@ -31,9 +31,11 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
31
31
|
// The URL the client signed its proof over. The Host header already
|
|
32
32
|
// passed the Authorities firewall, so whichever alias the client used
|
|
33
33
|
// (localhost, 127.0.0.1, the named origin) is one this agent answers on;
|
|
34
|
-
// the scheme is whichever listener the request arrived on.
|
|
34
|
+
// the scheme is whichever listener the request arrived on. `pathname` is
|
|
35
|
+
// relative to this identity's mount, so a suffix pod folds the mount back
|
|
36
|
+
// in — the client signed over the full path it actually requested.
|
|
35
37
|
const htu = `${scheme ? scheme.replace(/:$/u, '') : req.socket?.encrypted ? 'https' : 'http'
|
|
36
|
-
}://${req.headers.host}${pathname}`;
|
|
38
|
+
}://${req.headers.host}${mount}${pathname}`;
|
|
37
39
|
({ webid } = await v(
|
|
38
40
|
req.headers.authorization,
|
|
39
41
|
req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,
|