fedipod 1.48.0 → 1.49.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 +12 -71
- package/gateway.md +3 -3
- package/gui.md +2 -1
- package/lib/client/masto/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +1 -0
- package/lib/client/masto/oauth.mjs +62 -4
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- package/lib/core/place.mjs +10 -0
- package/lib/core/publisher/index.mjs +14 -2
- package/lib/core/store.mjs +6 -2
- package/lib/core/wire.mjs +3 -1
- package/lib/gateway/front-core.mjs +42 -45
- package/lib/gateway/gateway-core.mjs +6 -1
- package/lib/gateway/held-mail.mjs +23 -0
- package/lib/gateway/masto-gateway.mjs +54 -7
- package/lib/gateway/notices.mjs +1 -1
- package/lib/gateway/outbox-door.mjs +3 -3
- package/lib/gateway/private-read.mjs +7 -1
- package/lib/gateway/quiet.mjs +13 -3
- package/lib/gateway/state-api.mjs +7 -3
- package/lib/gateway/token-claims.mjs +66 -4
- package/lib/pod/actor.mjs +5 -0
- package/lib/pod/transport.mjs +28 -0
- package/lib/pod/type-index.mjs +23 -0
- package/lib/server/embed.mjs +2 -2
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/web/admin/index.html +2 -1
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/app/agent.mjs +29 -4
- package/web/app/dist/boot.js +27 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +106 -10
- package/web/app/dist/sw.js.map +3 -3
- package/web/front/run.js +1 -1
package/README.md
CHANGED
|
@@ -2,63 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
- access the Fediverse from a Solid pod
|
|
4
4
|
|
|
5
|
-
FediPod gives you a Fediverse account whose data lives on a Solid pod
|
|
6
|
-
follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in
|
|
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 every fifteen
|
|
9
|
-
minutes it writes that copy to your pod and deletes it; your signing key never
|
|
10
|
-
leaves your pod.
|
|
5
|
+
FediPod gives you a Fediverse account whose data lives on a Solid pod, and you
|
|
6
|
+
use it with any Fediverse client. You can follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in one timeline.
|
|
11
7
|
|
|
12
8
|
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.
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
This README is about the `FediPod BrowserAgent`. 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). Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
|
|
10
|
+
This README is about the `FediPod` as used with a Gateway. 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). Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
|
|
16
11
|
|
|
17
12
|
## Requirements
|
|
18
13
|
|
|
19
14
|
- A current browser
|
|
20
15
|
- A free account on a gateway such as https://fedipod.net
|
|
21
|
-
- A Solid pod
|
|
16
|
+
- A Solid pod (you can get one if you don't have one as part of account setup)
|
|
22
17
|
|
|
23
18
|
## Getting an account
|
|
24
19
|
|
|
25
20
|
1. Open https://fedipod.net and choose **create an account**.
|
|
26
21
|
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.
|
|
27
22
|
3. Back on fedipod.net, choose your handle and where your address lives:
|
|
28
|
-
on your pod, `@handle@yourpod`, or at
|
|
23
|
+
on your pod, `@handle@yourpod`, or at the gateway, `@handle@fedipod.net`.
|
|
29
24
|
Choose where on your pod your data goes: a container named `fedipod`,
|
|
30
25
|
inside whichever container you name (your pod's root if you leave it
|
|
31
|
-
empty).
|
|
32
|
-
to create one; FediPod records there where your account lives, and
|
|
33
|
-
nothing is set up without it.
|
|
26
|
+
empty).
|
|
34
27
|
4. You see your Fediverse timeline.
|
|
35
28
|
|
|
36
|
-
From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
|
|
37
|
-
|
|
38
|
-
## Getting a forum
|
|
39
|
-
|
|
40
|
-
The same page makes a forum. Choose **A forum** as the first thing on it. A
|
|
41
|
-
forum has a pod of its own, so make a new pod for it at your provider (or
|
|
42
|
-
sign in with one that holds nothing else), then give the forum a handle, a
|
|
43
|
-
name and its categories, and the address at fedipod.net you will moderate it
|
|
44
|
-
as. fedipod.net runs the forum from then on; nothing runs on your machine.
|
|
45
|
-
Its page is at `https://bb.fedipod.net/<handle>/` and its address is
|
|
46
|
-
`@handle@fedipod.net`, with one address per category.
|
|
47
|
-
|
|
48
29
|
## What you can do
|
|
49
30
|
|
|
50
|
-
There are two
|
|
51
|
-
[Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant),
|
|
52
|
-
which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)
|
|
53
|
-
(MIT, by Chee Aun). The links at the top right of every page switch between
|
|
31
|
+
You can access your account by pointing any Mastodon API or C2S client at fedipod.net and signing in. There are also two built-inclients, both served by fedipod.net: [Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant), which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)(MIT, by Chee Aun). The links at the top right of every page switch between
|
|
54
32
|
them, at any time; whichever you opened last is the one that opens next time.
|
|
55
33
|
|
|
56
|
-
You follow and unfollow, post, reply,
|
|
57
|
-
favourite, boost, attach media, edit and delete. Content warnings, polls with
|
|
58
|
-
up to four options and one or several answers, all four visibility levels, a
|
|
59
|
-
conversations view for direct messages, bookmarks, lists, keyword filters,
|
|
60
|
-
pinned posts that other servers can see, blocking and muting, and the custom
|
|
61
|
-
emojis other servers send.
|
|
34
|
+
You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists, keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
|
|
62
35
|
|
|
63
36
|
A post can quote another post. Quote is offered on posts whose author allows
|
|
64
37
|
it; the quoted author's server is asked, and the quote shows as pending until
|
|
@@ -108,50 +81,18 @@ the DeviceAgent has, described in [the admin interface](gui.md).
|
|
|
108
81
|
it in a second browser shows your timeline read-only, and the moment you act
|
|
109
82
|
there it takes over; the first drops back to reading.
|
|
110
83
|
|
|
111
|
-
## What the browser version does not do
|
|
112
|
-
|
|
113
|
-
- **Live updates.** The client refreshes by polling.
|
|
114
|
-
- **Hosting a group.** Joining one works.
|
|
115
|
-
|
|
116
|
-
For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
117
|
-
|
|
118
|
-
## Your data and your keys
|
|
119
|
-
|
|
120
|
-
Everything you publish and everything you read is stored on your pod. Your
|
|
121
|
-
signing key is stored there too, in a container only you can read through
|
|
122
|
-
your pod's login.
|
|
123
|
-
While fedipod.net keeps your account running, its own pod identity can read
|
|
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.
|
|
129
|
-
It all lives in the `fedipod` container you chose at sign-up. Your pod's
|
|
130
|
-
public type index records it, as an ActivityStreams actor, which is how
|
|
131
|
-
FediPod and other Solid apps find your account again.
|
|
132
|
-
fedipod.net holds no key: it verifies incoming mail, drops
|
|
133
|
-
the junk, forwards the rest to your pod, and hands your browser the app.
|
|
134
|
-
With an address on your pod, `@handle@yourpod`, you can detach from it at
|
|
135
|
-
any time and attach to a gateway of your own; your address and your data do
|
|
136
|
-
not change.
|
|
137
|
-
|
|
138
84
|
## Other ways to use FediPod
|
|
139
85
|
|
|
140
86
|
- [fediverse-session](https://github.com/jeff-zucker/fediverse-session) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
|
|
141
87
|
- [FediPod Server](https://github.com/jeff-zucker/fedipod-server): a full ActivityPub
|
|
142
88
|
server as a Community Solid Server component, giving every pod on the server
|
|
143
|
-
the option of a Fediverse account.
|
|
89
|
+
the option of a Fediverse account. See this link for how to host your own Solid-ActivityPub server.
|
|
144
90
|
- [FediPod BB](https://github.com/jeff-zucker/fedipod-bb) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
|
|
91
|
+
<!--
|
|
145
92
|
- [FediPod DeviceAgent](device-agent.md) a single-user AP server that lives partly in a gateway, partly on a local device, with all data stored on your pod.
|
|
93
|
+
-->
|
|
146
94
|
- [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
|
|
147
|
-
- [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents.
|
|
148
|
-
|
|
149
|
-
There are two ways to run FediPod: in a browser at a Gateway such as
|
|
150
|
-
fedipod.net, with nothing installed, or as a Gateway of your own, deployed
|
|
151
|
-
from this repository ([netlify/README.md](netlify/README.md)). Cloning the
|
|
152
|
-
repository and running it is not one of them: `npm start` only says so. The
|
|
153
|
-
DeviceAgent is deprecated; nothing new is built for it.
|
|
154
|
-
|
|
95
|
+
- [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. See this link for how to host your own gateway.
|
|
155
96
|
|
|
156
97
|
## Acknowledgements
|
|
157
98
|
|
package/gateway.md
CHANGED
|
@@ -242,9 +242,9 @@ from an app.
|
|
|
242
242
|
|
|
243
243
|
**Notifications on your phone.** An app that offers notifications can sign up
|
|
244
244
|
for them here. A mention, a reply, a direct message, a follow, or a like, boost
|
|
245
|
-
or reaction to your own post then reaches your phone as it arrives,
|
|
246
|
-
FediPod
|
|
247
|
-
|
|
245
|
+
or reaction to your own post then reaches your phone as it arrives, whether
|
|
246
|
+
FediPod is open in a browser or closed. Notifications from a connected Bluesky
|
|
247
|
+
or Mastodon account are not pushed.
|
|
248
248
|
|
|
249
249
|
## Accounts that go quiet
|
|
250
250
|
|
package/gui.md
CHANGED
|
@@ -19,7 +19,8 @@ The software row names the version the agent is running. When the copy on the ma
|
|
|
19
19
|
old handle keeps working as a redirect, and the identity is parked
|
|
20
20
|
afterwards.
|
|
21
21
|
* **Retire identity** is permanent — every follower's server is told to drop
|
|
22
|
-
the account, and
|
|
22
|
+
the account, your WebID profile and public type index stop naming it, and
|
|
23
|
+
the identity does not come back.
|
|
23
24
|
* In the **Upkeep** group: **Drain the inbox** fetches and handles everything
|
|
24
25
|
waiting now, and **Show dead letters** lists deliveries that failed for
|
|
25
26
|
good and will not be retried.
|
|
@@ -8,6 +8,16 @@ import * as podMedia from '../../pod/media.mjs';
|
|
|
8
8
|
import * as social from '../../core/social.mjs';
|
|
9
9
|
import { readBody, readMultipart } from './body.mjs';
|
|
10
10
|
|
|
11
|
+
// What a profile picture or header may be: an image of a kind every client
|
|
12
|
+
// shows, stored as that type and named by it, never by the client's filename.
|
|
13
|
+
// Anything else, a web page above all, would be served from the pod's own
|
|
14
|
+
// address and run there when opened.
|
|
15
|
+
const PROFILE_IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' };
|
|
16
|
+
const profileImageType = (f) => {
|
|
17
|
+
const t = String(f?.contentType || '').split(';')[0].trim().toLowerCase();
|
|
18
|
+
return PROFILE_IMAGE_EXT[t] ? t : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
11
21
|
export async function handle(api, ctx) {
|
|
12
22
|
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
13
23
|
|
|
@@ -38,13 +48,17 @@ export async function handle(api, ctx) {
|
|
|
38
48
|
else form = await readBody(req);
|
|
39
49
|
|
|
40
50
|
const cfg = { ...api.store.getConfig() };
|
|
51
|
+
for (const f of [ files.avatar, files.header ]) {
|
|
52
|
+
if (f?.data?.length && !profileImageType(f)) {
|
|
53
|
+
return send(422, { error: 'a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
41
56
|
const putImage = async (f) => {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
57
|
+
const type = profileImageType(f);
|
|
58
|
+
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + PROFILE_IMAGE_EXT[type];
|
|
45
59
|
const url = api.urls.media + slug;
|
|
46
60
|
await api.agent.publisher.ensureMediaContainer();
|
|
47
|
-
await podMedia.write(api.agent.remote, url, f.data,
|
|
61
|
+
await podMedia.write(api.agent.remote, url, f.data, type);
|
|
48
62
|
return url;
|
|
49
63
|
};
|
|
50
64
|
|
|
@@ -12,6 +12,10 @@ const AUTHZ_WINDOW_MS = 60_000;
|
|
|
12
12
|
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
13
13
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
14
14
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
15
|
+
const MAX_REDIRECTS = 10; // return addresses one client may register
|
|
16
|
+
const MAX_REDIRECT_CHARS = 2048;
|
|
17
|
+
const REGISTRATION_WINDOW_MS = 60_000;
|
|
18
|
+
const MAX_REGISTRATIONS = 10; // registrations per window, from anyone
|
|
15
19
|
const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
|
|
16
20
|
const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
|
|
17
21
|
const CLIENT_DOC_WINDOW_MS = 60_000;
|
|
@@ -48,12 +52,14 @@ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+
|
|
|
48
52
|
// fediverse-session library, and proves that sign-in back to /oauth/authorize.
|
|
49
53
|
function sendPodSigninPage(res, client, webId, mount = '') {
|
|
50
54
|
let asking = '';
|
|
55
|
+
let allow = 'Allow';
|
|
51
56
|
if (client && (client.name || client.redirect)) {
|
|
52
57
|
let where = '';
|
|
53
58
|
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
54
59
|
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
55
60
|
asking = `<p><strong>${who}</strong> is asking to use your account`
|
|
56
61
|
+ `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
|
|
62
|
+
allow = `Allow ${who}`;
|
|
57
63
|
}
|
|
58
64
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
|
|
59
65
|
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -64,13 +70,14 @@ h1{color:var(--heading);font-size:1.5rem}
|
|
|
64
70
|
code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
|
|
65
71
|
button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
|
|
66
72
|
button[hidden]{display:none}
|
|
73
|
+
#cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
|
|
67
74
|
#signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
|
|
68
|
-
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#
|
|
75
|
+
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
|
|
69
76
|
<main>
|
|
70
77
|
<h1>Sign in with your pod</h1>
|
|
71
78
|
${asking}
|
|
72
79
|
<p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
|
|
73
|
-
<p><button type="button" id="signin" hidden
|
|
80
|
+
<p><button type="button" id="signin" hidden>${allow}</button><button type="button" id="cancel" hidden>Cancel</button></p>
|
|
74
81
|
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
75
82
|
</main>
|
|
76
83
|
<script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
|
|
@@ -324,12 +331,53 @@ export function registerApp(api, { name, website, redirectUris, scopes }) {
|
|
|
324
331
|
clientSecret: crypto.randomBytes(32).toString('base64url'),
|
|
325
332
|
name: String(name || 'client').slice(0, 200),
|
|
326
333
|
website: String(website || '').slice(0, 500),
|
|
327
|
-
redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
|
|
334
|
+
redirectUris, scopes: String(scopes || 'read').slice(0, 500), createdAt: Date.now(),
|
|
335
|
+
// Set when the owner first lets it sign in (touchApp): a client nobody
|
|
336
|
+
// has used is the first to go when the list is full.
|
|
337
|
+
usedAt: null,
|
|
328
338
|
};
|
|
329
|
-
|
|
339
|
+
const kept = api.apps();
|
|
340
|
+
while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
|
|
341
|
+
api.store.write('oauth-apps.json', [...kept, app]);
|
|
330
342
|
return app;
|
|
331
343
|
}
|
|
332
344
|
|
|
345
|
+
// The client to push out of a full list: one nobody has signed in with,
|
|
346
|
+
// oldest first; failing that, the one used longest ago. A client registered
|
|
347
|
+
// before `usedAt` existed counts as used when it was made, so a flood of new
|
|
348
|
+
// registrations cannot push out the apps the owner already has.
|
|
349
|
+
function leastUsed(list) {
|
|
350
|
+
const never = list.filter((a) => a.usedAt === null);
|
|
351
|
+
const when = (a) => a.usedAt ?? a.createdAt ?? 0;
|
|
352
|
+
return (never.length ? never : list).reduce((a, b) => (when(b) < when(a) ? b : a));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function touchApp(api, clientId) {
|
|
356
|
+
const all = api.apps();
|
|
357
|
+
const app = all.find((a) => a.clientId === clientId);
|
|
358
|
+
if (!app) return;
|
|
359
|
+
app.usedAt = Date.now();
|
|
360
|
+
api.store.write('oauth-apps.json', all);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Why a registration's return addresses cannot be kept, or null. Registering
|
|
364
|
+
// needs no sign-in, so its size is the part a stranger controls.
|
|
365
|
+
export function redirectsProblem(raw) {
|
|
366
|
+
if (Array.isArray(raw) && raw.some((v) => typeof v !== 'string')) return 'redirect_uris must be text';
|
|
367
|
+
const list = parseRedirects(raw);
|
|
368
|
+
if (list.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
|
|
369
|
+
if (list.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function registrationLimited(api) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
|
|
376
|
+
if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
|
|
377
|
+
api.appRegistrations.push(now);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
333
381
|
// A short-lived, single-use authorization code for a registered client, kept
|
|
334
382
|
// apart from masto-tokens.json so the code is NOT a bearer until it is
|
|
335
383
|
// exchanged with the client secret.
|
|
@@ -398,6 +446,13 @@ export async function handle(api, ctx) {
|
|
|
398
446
|
// authorize step becomes a real login form.
|
|
399
447
|
if (pathname === '/api/v1/apps' && req.method === 'POST') {
|
|
400
448
|
const body = await readBody(req);
|
|
449
|
+
const problem = redirectsProblem(body.redirect_uris);
|
|
450
|
+
if (problem) return send(422, { error: problem });
|
|
451
|
+
if (registrationLimited(api)) {
|
|
452
|
+
api.log('app registration refused: too many this minute');
|
|
453
|
+
return send(429, { error: 'too many apps registered just now — try again in a minute' },
|
|
454
|
+
{ 'retry-after': String(REGISTRATION_WINDOW_MS / 1000) });
|
|
455
|
+
}
|
|
401
456
|
const redirectUris = parseRedirects(body.redirect_uris);
|
|
402
457
|
const app = api.registerApp({ name: body.client_name, website: body.website,
|
|
403
458
|
redirectUris, scopes: body.scopes });
|
|
@@ -543,6 +598,7 @@ export async function handle(api, ctx) {
|
|
|
543
598
|
challenge: params.get('code_challenge') || null,
|
|
544
599
|
challengeMethod: params.get('code_challenge_method') || null })
|
|
545
600
|
: api.mintToken(client.scope);
|
|
601
|
+
if (app) touchApp(api, app.clientId);
|
|
546
602
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
547
603
|
const target = new URL(redirect);
|
|
548
604
|
target.searchParams.set('code', code);
|
|
@@ -647,6 +703,8 @@ export async function handle(api, ctx) {
|
|
|
647
703
|
if (gone) {
|
|
648
704
|
const kept = api.tokenRecords().filter(r => r.token !== gone);
|
|
649
705
|
api.store.write('masto-tokens.json', kept);
|
|
706
|
+
// Its notifications stop with it.
|
|
707
|
+
if (api.push.get(gone)) api.push.drop(gone);
|
|
650
708
|
api.log('client token revoked');
|
|
651
709
|
}
|
|
652
710
|
return send(200, {});
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
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
|
+
import { scopeAllows } from './masto/oauth.mjs';
|
|
12
|
+
import { claimedWebId } from '../gateway/token-claims.mjs';
|
|
13
|
+
|
|
11
14
|
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
|
|
12
15
|
let verify = verifier;
|
|
13
16
|
const loadVerifier = async () => {
|
|
@@ -19,7 +22,14 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
return async function authenticate(req, pathname) {
|
|
22
|
-
|
|
25
|
+
const bearer = masto?.tokenOf(req);
|
|
26
|
+
if (bearer) {
|
|
27
|
+
// The same permission the Mastodon routes ask of this token: a client
|
|
28
|
+
// allowed only to read does not post here either.
|
|
29
|
+
const need = req.method === 'GET' || req.method === 'HEAD' ? 'read' : 'write';
|
|
30
|
+
if (!scopeAllows(bearer.scope, need)) {
|
|
31
|
+
return { ok: false, status: 403, error: `this app was not allowed to ${need === 'read' ? 'read' : 'post'} for the account` };
|
|
32
|
+
}
|
|
23
33
|
return { ok: true, webid: agent.remote?.webId || null, via: 'bearer' };
|
|
24
34
|
}
|
|
25
35
|
if (!req.headers.authorization) {
|
|
@@ -32,6 +42,12 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
32
42
|
return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
|
|
33
43
|
headers: { 'www-authenticate': 'DPoP' } };
|
|
34
44
|
}
|
|
45
|
+
// A token naming anyone but the owner is refused before it is checked:
|
|
46
|
+
// checking fetches the documents it names, and a stranger names them.
|
|
47
|
+
const claimed = claimedWebId({ headers: { get: (n) => req.headers[n] ?? null } });
|
|
48
|
+
if (claimed && claimed !== agent.remote?.webId) {
|
|
49
|
+
return { ok: false, status: 403, error: 'this account belongs to its owner alone' };
|
|
50
|
+
}
|
|
35
51
|
let webid;
|
|
36
52
|
try {
|
|
37
53
|
const v = await loadVerifier();
|
|
@@ -48,8 +64,10 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
48
64
|
{ header: req.headers.dpop, method: req.method, url: htu },
|
|
49
65
|
));
|
|
50
66
|
} catch (e) {
|
|
67
|
+
// The reason stays here: it can carry what was read from an address
|
|
68
|
+
// the token named.
|
|
51
69
|
log(`c2s auth: token rejected — ${e.message}`);
|
|
52
|
-
return { ok: false, status: 401, error:
|
|
70
|
+
return { ok: false, status: 401, error: 'token rejected' };
|
|
53
71
|
}
|
|
54
72
|
const owner = agent.remote?.webId;
|
|
55
73
|
if (!owner || webid !== owner) {
|
package/lib/client/webpush.mjs
CHANGED
|
@@ -7,10 +7,13 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import webpush from 'web-push';
|
|
8
8
|
|
|
9
9
|
export class Push {
|
|
10
|
-
constructor({ store, subject, log = () => {} }) {
|
|
10
|
+
constructor({ store, subject, log = () => {}, liveKeys = null }) {
|
|
11
11
|
this.store = store;
|
|
12
12
|
this.subject = subject; // () => the actor URL, VAPID's contact
|
|
13
13
|
this.log = log;
|
|
14
|
+
// () => the keys of the sign-ins still in force, where they are kept beside
|
|
15
|
+
// the subscriptions; without it no subscription is forgotten for its sign-in.
|
|
16
|
+
this.liveKeys = liveKeys;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
state() { return this.store.read('webpush.json', { subs: {} }); }
|
|
@@ -68,14 +71,21 @@ export class Push {
|
|
|
68
71
|
|
|
69
72
|
// Push one notification to every subscription whose alerts allow the type.
|
|
70
73
|
// A push service answering 404/410 means the browser dropped the
|
|
71
|
-
// subscription — it is removed rather than retried forever.
|
|
74
|
+
// subscription — it is removed rather than retried forever. With
|
|
75
|
+
// `liveKeys`, every subscription whose sign-in is no longer in force is
|
|
76
|
+
// forgotten first: an app signed out, expired or pushed off the list hears
|
|
77
|
+
// nothing more.
|
|
72
78
|
async notify(n, payload) {
|
|
73
79
|
const st = this.state();
|
|
80
|
+
let dropped = false;
|
|
81
|
+
const live = this.liveKeys?.() || null;
|
|
82
|
+
if (live) {
|
|
83
|
+
for (const key of Object.keys(st.subs)) if (!live.has(key)) { delete st.subs[key]; dropped = true; }
|
|
84
|
+
}
|
|
74
85
|
const entries = Object.entries(st.subs);
|
|
75
|
-
if (!entries.length) return;
|
|
86
|
+
if (!entries.length) { if (dropped) this.save(st); return; }
|
|
76
87
|
const { publicKey, privateKey } = this.vapid();
|
|
77
88
|
const details = { subject: this.subject(), publicKey, privateKey };
|
|
78
|
-
let dropped = false;
|
|
79
89
|
for (const [key, sub] of entries) {
|
|
80
90
|
if (sub.alerts && sub.alerts[n.type] === false) continue;
|
|
81
91
|
try {
|
package/lib/core/place.mjs
CHANGED
|
@@ -52,6 +52,16 @@ export async function recordPlace(pod, podBase, actorAtPod, { create = false } =
|
|
|
52
52
|
return (await typeIndex.register(pod, index, actorAtPod)) ? 'registered' : 'already';
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Take the account's place out of the owner's public type index, for an
|
|
57
|
+
* account retired for good. Returns 'removed', 'absent' or 'no-index'.
|
|
58
|
+
*/
|
|
59
|
+
export async function forgetPlace(pod, podBase, actorAtPod) {
|
|
60
|
+
const index = await typeIndex.findPublicIndex(pod, podBase);
|
|
61
|
+
if (!index) return 'no-index';
|
|
62
|
+
return (await typeIndex.unregister(pod, index, actorAtPod)) ? 'removed' : 'absent';
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
/** Whether this person's profile names a public type index. */
|
|
56
66
|
export async function hasPublicIndex(pod, podBase) {
|
|
57
67
|
return !!(await typeIndex.findPublicIndex(pod, podBase).catch(() => null));
|
|
@@ -26,7 +26,7 @@ import * as restore from './restore.mjs';
|
|
|
26
26
|
import * as notes from './notes.mjs';
|
|
27
27
|
import * as questions from './questions.mjs';
|
|
28
28
|
import * as own from './own.mjs';
|
|
29
|
-
import { recordPlace } from '../place.mjs';
|
|
29
|
+
import { recordPlace, forgetPlace } from '../place.mjs';
|
|
30
30
|
|
|
31
31
|
// The version is read where a bundled deploy (the Netlify functions) cannot
|
|
32
32
|
// break it: a package.json that is not beside the bundle is not a reason for
|
|
@@ -212,7 +212,7 @@ export class Publisher {
|
|
|
212
212
|
// writes none — the forum's own actor, or a Gateway, answers for it.
|
|
213
213
|
if (!this.config.forum) {
|
|
214
214
|
await discovery.writeWebfinger(this.remote, urls,
|
|
215
|
-
wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
|
|
215
|
+
wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml, avatar: this.config.icon || null }));
|
|
216
216
|
await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
|
|
217
217
|
|
|
218
218
|
const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
|
|
@@ -393,6 +393,18 @@ export class Publisher {
|
|
|
393
393
|
await podActor.writeTombstone(this.remote, urls, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
|
|
394
394
|
this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
|
|
395
395
|
await this.store.flush();
|
|
396
|
+
// The owner's own documents stop pointing at it: the WebID profile's
|
|
397
|
+
// account and outbox links, and its place in the public type index.
|
|
398
|
+
// Best-effort, as writing them was.
|
|
399
|
+
if (!this.config.forum) {
|
|
400
|
+
await podActor.unlinkInWebIdProfile(this.remote, {
|
|
401
|
+
actorUrl: urls.actor, outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
|
|
402
|
+
}).then((n) => { if (n) this.log('the WebID profile no longer lists the actor'); })
|
|
403
|
+
.catch((e) => this.log(`the WebID profile still lists the actor: ${e.message}`));
|
|
404
|
+
await forgetPlace(this.remote, urls.base, urls.home + 'ap/actor')
|
|
405
|
+
.then((r) => { if (r === 'removed') this.log('the public type index no longer records the account'); })
|
|
406
|
+
.catch((e) => this.log(`the type index still records the account: ${e.message}`));
|
|
407
|
+
}
|
|
396
408
|
this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
|
|
397
409
|
return { inboxes: inboxes.length, deletedAt };
|
|
398
410
|
}
|
package/lib/core/store.mjs
CHANGED
|
@@ -185,9 +185,13 @@ export class PodStore {
|
|
|
185
185
|
// Read these documents again, whatever is held: a kept copy caught them
|
|
186
186
|
// mid-write, so the pod's copy is the one to hold. Throws when one cannot be
|
|
187
187
|
// read, so the caller can fall back to a full load rather than act without it.
|
|
188
|
-
|
|
188
|
+
// `ifChanged`: a document held here is asked for only if it changed since — a
|
|
189
|
+
// pod's listing does not change when a document in it is rewritten.
|
|
190
|
+
async refresh(names, { ifChanged = false } = {}) {
|
|
189
191
|
for (const name of names) {
|
|
190
|
-
const
|
|
192
|
+
const etag = ifChanged && this.cache.has(name) ? this.etags.get(name) : null;
|
|
193
|
+
const r = await this.storage.read(name, etag ? { etag } : undefined);
|
|
194
|
+
if (r.notModified) continue;
|
|
191
195
|
if (r.status === 404) {
|
|
192
196
|
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
|
|
193
197
|
} else if (!r.ok) {
|
package/lib/core/wire.mjs
CHANGED
|
@@ -56,13 +56,15 @@ export function hostMeta(base) {
|
|
|
56
56
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function jrd({ handle, host, actor, aliases = [], page = null }) {
|
|
59
|
+
export function jrd({ handle, host, actor, aliases = [], page = null, avatar = null }) {
|
|
60
60
|
return {
|
|
61
61
|
subject: `acct:${handle}@${host}`,
|
|
62
62
|
...(aliases.length ? { aliases } : {}),
|
|
63
63
|
links: [
|
|
64
64
|
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
65
65
|
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
66
|
+
// The account's picture, as Mastodon's answer names it.
|
|
67
|
+
...(avatar ? [{ rel: 'http://webfinger.net/rel/avatar', href: avatar }] : []),
|
|
66
68
|
],
|
|
67
69
|
};
|
|
68
70
|
}
|