fedipod 1.43.0 → 1.45.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 +16 -0
- package/gateway.md +7 -3
- package/lib/client/c2s.mjs +21 -73
- package/lib/core/intake/index.mjs +8 -1
- package/lib/core/intake/received.mjs +78 -0
- package/lib/gateway/front-core.mjs +33 -14
- package/lib/gateway/front-pages.mjs +2 -4
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/held-mail.mjs +7 -0
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/private-read.mjs +91 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/package.json +3 -3
- package/scripts/check-pod-calls.mjs +1 -1
- package/scripts/start.mjs +13 -0
- package/web/app/boot.mjs +65 -7
- package/web/app/dist/boot.js +764 -10
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/forum-form.mjs +38 -0
- package/web/app/forum-support.mjs +11 -0
- package/web/app/index.html +29 -4
- package/web/app/signup-forum.mjs +52 -0
- package/web/app/signup.mjs +2 -2
- package/web/front/new-account.html +8 -16
- package/web/front/new-account.js +3 -7
- package/web/front/install.sh +0 -69
package/README.md
CHANGED
|
@@ -34,6 +34,16 @@ This README is about the `FediPod BrowserAgent`. There are also a number of [oth
|
|
|
34
34
|
|
|
35
35
|
From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
|
|
36
36
|
|
|
37
|
+
## Getting a forum
|
|
38
|
+
|
|
39
|
+
The same page makes a forum. Choose **A forum** as the first thing on it. A
|
|
40
|
+
forum has a pod of its own, so make a new pod for it at your provider (or
|
|
41
|
+
sign in with one that holds nothing else), then give the forum a handle, a
|
|
42
|
+
name and its categories, and the address at fedipod.net you will moderate it
|
|
43
|
+
as. fedipod.net runs the forum from then on; nothing runs on your machine.
|
|
44
|
+
Its page is at `https://bb.fedipod.net/<handle>/` and its address is
|
|
45
|
+
`@handle@fedipod.net`, with one address per category.
|
|
46
|
+
|
|
37
47
|
## What you can do
|
|
38
48
|
|
|
39
49
|
There are two clients, both served by fedipod.net:
|
|
@@ -141,6 +151,12 @@ not change.
|
|
|
141
151
|
- [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
|
|
142
152
|
- [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
|
|
143
153
|
|
|
154
|
+
There are two ways to run FediPod: in a browser at a Gateway such as
|
|
155
|
+
fedipod.net, with nothing installed, or as a Gateway of your own, deployed
|
|
156
|
+
from this repository ([netlify/README.md](netlify/README.md)). Cloning the
|
|
157
|
+
repository and running it is not one of them: `npm start` only says so. The
|
|
158
|
+
DeviceAgent is deprecated; nothing new is built for it.
|
|
159
|
+
|
|
144
160
|
|
|
145
161
|
## Acknowledgements
|
|
146
162
|
|
package/gateway.md
CHANGED
|
@@ -15,7 +15,10 @@ yours, which your agent then signs and sends. It still holds no key, but what yo
|
|
|
15
15
|
trust it with grows by that much. A browser account is also kept running by
|
|
16
16
|
the gateway (below). That gives the gateway your key while it works, so it can
|
|
17
17
|
post as you, as any Fediverse server can, and your account's working data is
|
|
18
|
-
kept at the gateway too and written to your pod every fifteen minutes.
|
|
18
|
+
kept at the gateway too and written to your pod every fifteen minutes. For
|
|
19
|
+
a kept account the gateway also answers a follower's server asking for one
|
|
20
|
+
of your followers-only posts, and the named person's server asking for a
|
|
21
|
+
direct one, when that server signs its request; nobody else gets them.
|
|
19
22
|
|
|
20
23
|
A FediPod install works without any gateway at all. Deliveries go straight to
|
|
21
24
|
your pod inbox, which holds them whether your agent is running or not.
|
|
@@ -73,8 +76,9 @@ mode says how far you trust the door, and every step is reversible:
|
|
|
73
76
|
- **locked** — your inbox accepts writes only from the gateway.
|
|
74
77
|
|
|
75
78
|
In every mode past **off**, the door itself is already filtering: blocked
|
|
76
|
-
actors
|
|
77
|
-
|
|
79
|
+
actors and content that does not concern you are dropped at the door and
|
|
80
|
+
never reach the pod; a delivery whose signature does not verify is passed on
|
|
81
|
+
marked unverified, and the agent decides by fetching what it claims to be. The mode says only how far the agent
|
|
78
82
|
believes the door's receipts.
|
|
79
83
|
|
|
80
84
|
The shadow numbers come from the agent:
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -32,11 +32,6 @@ import * as podNotes from '../pod/notes.mjs';
|
|
|
32
32
|
|
|
33
33
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
34
34
|
|
|
35
|
-
// How many archived items one page of the inbox will read. A page is one
|
|
36
|
-
// month, and a month with more than this is served short rather than costing
|
|
37
|
-
// the pod an unbounded read; the log says when that happened.
|
|
38
|
-
const MAX_INBOX_PAGE = 500;
|
|
39
|
-
|
|
40
35
|
// How many recorded activities one page of the locally-served outbox carries.
|
|
41
36
|
const MAX_OUTBOX_PAGE = 50;
|
|
42
37
|
|
|
@@ -99,82 +94,35 @@ export class C2S {
|
|
|
99
94
|
return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
|
|
100
95
|
}
|
|
101
96
|
|
|
102
|
-
/** The months the archive holds, newest first. One container listing. */
|
|
103
|
-
async archiveMonths() {
|
|
104
|
-
const archive = this.agent.intake?.archive;
|
|
105
|
-
if (!archive) return [];
|
|
106
|
-
const { names } = await archive.list('');
|
|
107
|
-
return (names || [])
|
|
108
|
-
.map((n) => n.replace(/\/$/u, ''))
|
|
109
|
-
.filter((n) => /^\d{4}-\d{2}$/u.test(n))
|
|
110
|
-
.sort()
|
|
111
|
-
.reverse();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
97
|
/**
|
|
115
|
-
* The owner's own inbox, §5.2
|
|
116
|
-
*
|
|
117
|
-
*
|
|
98
|
+
* The owner's own inbox, §5.2: the collection the drain keeps on the pod in
|
|
99
|
+
* the private container (intake/received.mjs), served under this agent's
|
|
100
|
+
* address so a client holding no pod credential of its own can read it.
|
|
101
|
+
* Nothing is built per request; a page is the pod's page.
|
|
118
102
|
*/
|
|
119
|
-
async sendInbox(res, url) {
|
|
120
|
-
const id = `${
|
|
103
|
+
async sendInbox(res, url, origin) {
|
|
104
|
+
const id = `${origin}ap/inbox`; // named for the client that asked, as the outbox is
|
|
105
|
+
const own = this.urls.ownInbox;
|
|
121
106
|
const page = url?.searchParams?.get('page') || null;
|
|
122
|
-
|
|
107
|
+
if (page !== null && !/^[1-9]\d{0,6}$/u.test(page)) return this.send(res, 400, { error: 'page is a number' });
|
|
108
|
+
let doc;
|
|
123
109
|
try {
|
|
124
|
-
|
|
110
|
+
doc = await this.agent.remote.getJson(page ? `${own}-${page}` : own);
|
|
125
111
|
} catch (e) {
|
|
126
|
-
return this.send(res, 502, { error: `the
|
|
112
|
+
return this.send(res, 502, { error: `the inbox collection could not be read: ${e.message}` });
|
|
127
113
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
114
|
+
if (!doc) {
|
|
115
|
+
if (page) return this.send(res, 404, { error: 'no such page' });
|
|
116
|
+
if (this.store.getConfig()?.archiveInbox === false) {
|
|
131
117
|
this.log('inbox read: nothing to show — this identity does not keep what it receives');
|
|
132
118
|
}
|
|
133
|
-
return this.send(res, 200, {
|
|
134
|
-
'@context': wire.AS_CTX, id, type: 'OrderedCollection',
|
|
135
|
-
...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
if (!/^\d{4}-\d{2}$/u.test(page)) {
|
|
139
|
-
return this.send(res, 400, { error: 'page names a month, written 2026-09' });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const archive = this.agent.intake?.archive;
|
|
143
|
-
let names = [];
|
|
144
|
-
try {
|
|
145
|
-
// The trailing slash matters: without it this names a document, not the
|
|
146
|
-
// container, and a pod answers about the wrong thing.
|
|
147
|
-
({ names } = await archive.list(`${page}/`));
|
|
148
|
-
} catch (e) {
|
|
149
|
-
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
150
|
-
}
|
|
151
|
-
const files = (names || []).filter((n) => n.endsWith('.json')).sort();
|
|
152
|
-
if (files.length > MAX_INBOX_PAGE) {
|
|
153
|
-
this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
|
|
119
|
+
return this.send(res, 200, { '@context': wire.AS_CTX, id, type: 'OrderedCollection', totalItems: 0, orderedItems: [] });
|
|
154
120
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!read?.ok || !read.body) continue;
|
|
161
|
-
try {
|
|
162
|
-
const record = JSON.parse(read.body);
|
|
163
|
-
// The record wraps the bytes as they arrived; the activity is those
|
|
164
|
-
// bytes, not a retelling of them.
|
|
165
|
-
kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
|
|
166
|
-
} catch { /* a record that will not parse is not one that can be served */ }
|
|
167
|
-
}
|
|
168
|
-
kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
169
|
-
const older = months.filter((m) => m < page)[0] || null;
|
|
170
|
-
return this.send(res, 200, {
|
|
171
|
-
'@context': wire.AS_CTX,
|
|
172
|
-
id: `${id}?page=${page}`,
|
|
173
|
-
type: 'OrderedCollectionPage',
|
|
174
|
-
partOf: id,
|
|
175
|
-
...(older ? { next: `${id}?page=${older}` } : {}),
|
|
176
|
-
orderedItems: kept.map((k) => k.activity),
|
|
177
|
-
});
|
|
121
|
+
// The pod's names become this agent's: the head is `id`, page n is `id?page=n`.
|
|
122
|
+
const here = (v) => (typeof v === 'string' && v.startsWith(own)
|
|
123
|
+
? (v === own ? id : v.replace(`${own}-`, `${id}?page=`)) : v);
|
|
124
|
+
for (const k of ['id', 'partOf', 'first', 'last', 'next', 'prev']) if (k in doc) doc[k] = here(doc[k]);
|
|
125
|
+
return this.send(res, 200, doc);
|
|
178
126
|
}
|
|
179
127
|
|
|
180
128
|
// The actor a client-to-server client on this machine reads. The same
|
|
@@ -277,7 +225,7 @@ export class C2S {
|
|
|
277
225
|
}
|
|
278
226
|
const reader = await this.auth(req, pathname);
|
|
279
227
|
if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
|
|
280
|
-
return this.sendInbox(res, url);
|
|
228
|
+
return this.sendInbox(res, url, this.localOrigin(req) || this.urls.base);
|
|
281
229
|
}
|
|
282
230
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
283
231
|
// The owner, signed in, reads every message the actor produced (§5.1:
|
|
@@ -28,6 +28,7 @@ import { trimActivity, sameIdentity, sameOrigin, httpUrl, reactionOf, MAX_EMOJIS
|
|
|
28
28
|
import * as channel from './channel.mjs';
|
|
29
29
|
import * as verify from './verify.mjs';
|
|
30
30
|
import * as group from './group.mjs';
|
|
31
|
+
import * as received from './received.mjs';
|
|
31
32
|
import * as activities from './activities.mjs';
|
|
32
33
|
import * as notes from './notes.mjs';
|
|
33
34
|
import { readLenient } from '../as2.mjs';
|
|
@@ -489,6 +490,9 @@ export class Intake {
|
|
|
489
490
|
if (pending.length >= DELETE_BATCH && !await flush()) return;
|
|
490
491
|
}
|
|
491
492
|
if (!await this._finishSweep(flush)) return;
|
|
493
|
+
// What this sweep accepted, onto the pod as the owner's inbox collection:
|
|
494
|
+
// one page write and the head per sweep with mail, never per item.
|
|
495
|
+
await received.publishReceived(this).catch((e) => this.log(`inbox collection: ${e.message}`));
|
|
492
496
|
// Receipts earlier drains left behind, a few per sweep until none remain.
|
|
493
497
|
for (const stray of podInbox.orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
|
|
494
498
|
if (!await podInbox.dropStrayReceipt(this.remote, stray)) break;
|
|
@@ -532,7 +536,10 @@ export class Intake {
|
|
|
532
536
|
const rejection = !activity ? 'unparsable JSON'
|
|
533
537
|
: owned ? await this.ownerPostFrom(activity, raw, receipt)
|
|
534
538
|
: await this.handle(activity, receipt);
|
|
535
|
-
if (!rejection && raw && !owned)
|
|
539
|
+
if (!rejection && raw && !owned) {
|
|
540
|
+
await this._archive(url, raw, activity);
|
|
541
|
+
received.recordReceived(this, activity); // §5.2: the owner's readable inbox
|
|
542
|
+
}
|
|
536
543
|
if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
|
|
537
544
|
if (rejection) {
|
|
538
545
|
this.store.addDeadLetter({
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// received.mjs — the inbox as its owner reads it (ActivityPub §5.2): every
|
|
2
|
+
// delivery the account accepted, kept on the pod as a paged OrderedCollection
|
|
3
|
+
// in the private container, beside the archive that keeps each delivery's
|
|
4
|
+
// bytes. The door and the agent's own /ap/inbox serve these documents.
|
|
5
|
+
//
|
|
6
|
+
// Pages fill from the oldest end, PAGE_SIZE to a page, and a full page is
|
|
7
|
+
// never written again: an accepted delivery rewrites the newest page and the
|
|
8
|
+
// small head, and nothing else. Activities ride inline, so a reader gets what
|
|
9
|
+
// arrived without a fetch per item — a Like or a Follow has no address its
|
|
10
|
+
// origin answers anyway. Past ITEM_MAX_CHARS the item is the activity's id
|
|
11
|
+
// alone, so one enormous delivery cannot make a page of them enormous.
|
|
12
|
+
//
|
|
13
|
+
// §5.2 also says an activity delivered twice is listed once. The archive
|
|
14
|
+
// already collapses identical bytes; this keeps the last SEEN_MAX ids and
|
|
15
|
+
// drops a repeat that arrived with different addressing.
|
|
16
|
+
//
|
|
17
|
+
// Written only while the archive is on — an owner who chose not to keep what
|
|
18
|
+
// arrives keeps nothing here either — and only once the private container is
|
|
19
|
+
// proved private, the bar every owner-only document clears. Recorded meanwhile,
|
|
20
|
+
// and published the first time the bar is met.
|
|
21
|
+
|
|
22
|
+
import * as wire from '../wire.mjs';
|
|
23
|
+
import * as collection from '../../pod/collection.mjs';
|
|
24
|
+
|
|
25
|
+
export const PAGE_SIZE = wire.FOLLOWERS_PAGE_SIZE;
|
|
26
|
+
export const SEEN_MAX = 500;
|
|
27
|
+
export const ITEM_MAX_CHARS = 32 * 1024;
|
|
28
|
+
// Recorded but unpublished items are held in state; past this many the oldest
|
|
29
|
+
// are let go (the archive still has them) rather than growing state forever.
|
|
30
|
+
const HELD_MAX = PAGE_SIZE * 5;
|
|
31
|
+
const RECEIVED = 'received.json';
|
|
32
|
+
|
|
33
|
+
const fresh = () => ({ sealed: 0, open: [], total: 0, seen: [] });
|
|
34
|
+
|
|
35
|
+
const keeps = (intake) => !!intake.archive && !!intake.urls?.ownInbox
|
|
36
|
+
&& intake.store.getConfig?.()?.archiveInbox !== false;
|
|
37
|
+
|
|
38
|
+
/** Record one accepted delivery. False when it was a repeat, or nothing is kept. */
|
|
39
|
+
export function recordReceived(intake, activity) {
|
|
40
|
+
if (!keeps(intake) || !activity || typeof activity !== 'object') return false;
|
|
41
|
+
const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
|
|
42
|
+
const id = typeof activity.id === 'string' ? activity.id : null;
|
|
43
|
+
if (id && st.seen.includes(id)) return false;
|
|
44
|
+
st.open.push(id && JSON.stringify(activity).length > ITEM_MAX_CHARS ? id : activity);
|
|
45
|
+
if (st.open.length > HELD_MAX) st.open.splice(0, st.open.length - HELD_MAX);
|
|
46
|
+
st.total += 1;
|
|
47
|
+
if (id) {
|
|
48
|
+
st.seen.push(id);
|
|
49
|
+
if (st.seen.length > SEEN_MAX) st.seen.splice(0, st.seen.length - SEEN_MAX);
|
|
50
|
+
}
|
|
51
|
+
intake.store.write(RECEIVED, st);
|
|
52
|
+
intake._receivedDirty = true;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write what was recorded since the last publish: the newest page(s) and the head. */
|
|
57
|
+
export async function publishReceived(intake) {
|
|
58
|
+
if (!intake._receivedDirty || !keeps(intake)) return;
|
|
59
|
+
const ready = intake.publisher?.privateReady;
|
|
60
|
+
if (typeof ready === 'function' && await ready.call(intake.publisher) !== true) return;
|
|
61
|
+
const { remote, urls } = intake;
|
|
62
|
+
const st = { ...fresh(), ...intake.store.read(RECEIVED, {}) };
|
|
63
|
+
// Every full page first (several when the bar was met late), then the open one.
|
|
64
|
+
do {
|
|
65
|
+
const n = st.sealed + 1;
|
|
66
|
+
const items = st.open.slice(0, PAGE_SIZE);
|
|
67
|
+
await collection.writePage(remote, wire.followersPageId(urls.ownInbox, n),
|
|
68
|
+
wire.followersPage(urls.ownInbox, n, items, n));
|
|
69
|
+
if (items.length < PAGE_SIZE) break;
|
|
70
|
+
st.sealed = n;
|
|
71
|
+
st.open = st.open.slice(PAGE_SIZE);
|
|
72
|
+
intake.store.write(RECEIVED, st);
|
|
73
|
+
} while (st.open.length);
|
|
74
|
+
const pages = Math.max(1, st.sealed + (st.open.length ? 1 : 0));
|
|
75
|
+
await collection.writeHead(remote, urls.ownInbox, wire.followersHead(urls.ownInbox, st.total, pages));
|
|
76
|
+
intake.store.write(RECEIVED, st);
|
|
77
|
+
intake._receivedDirty = false;
|
|
78
|
+
}
|
|
@@ -28,6 +28,7 @@ import { routeNoticesApi } from './notices.mjs';
|
|
|
28
28
|
import { withSecurityHeaders } from './headers.mjs';
|
|
29
29
|
import { podTokenVerifier } from './caches.mjs';
|
|
30
30
|
import { claimedWebId } from './token-claims.mjs';
|
|
31
|
+
import { answerPrivateRead } from './private-read.mjs';
|
|
31
32
|
import { announced, overLimit, relayOne } from './relay-extras.mjs';
|
|
32
33
|
|
|
33
34
|
// The one WebFinger document, spelled out here rather than imported from
|
|
@@ -740,11 +741,6 @@ async function route(request, ctx) {
|
|
|
740
741
|
return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
|
|
741
742
|
}
|
|
742
743
|
|
|
743
|
-
// The installer: curl -fsSL https://<host>/install | sh
|
|
744
|
-
if (pathname === '/install') {
|
|
745
|
-
if (!ctx.installScript) return notFound();
|
|
746
|
-
return { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: ctx.installScript };
|
|
747
|
-
}
|
|
748
744
|
|
|
749
745
|
// WebFinger: acct:<handle>@<host> → the fronted actor. This is what makes
|
|
750
746
|
// @name@fedipod.net a real handle the fediverse can look up.
|
|
@@ -804,18 +800,36 @@ async function route(request, ctx) {
|
|
|
804
800
|
const CLOSED = 'this address is closed';
|
|
805
801
|
|
|
806
802
|
// Inbox: verify at the door, forward to the user's pod inbox. This is the
|
|
807
|
-
// gateway, per user.
|
|
803
|
+
// gateway, per user. A forum's own page posts here from its own host, so the
|
|
804
|
+
// door answers a browser's preflight and carries the open origin; a delivery
|
|
805
|
+
// needs no credential, and a browser sends none across origins.
|
|
808
806
|
if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
|
|
807
|
+
const doorCors = {
|
|
808
|
+
'access-control-allow-origin': '*',
|
|
809
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
810
|
+
'access-control-allow-headers': 'Content-Type',
|
|
811
|
+
'access-control-max-age': '86400',
|
|
812
|
+
};
|
|
813
|
+
if (request.method === 'OPTIONS') return { status: 204, headers: { ...doorCors, allow: 'POST, OPTIONS' }, body: null };
|
|
814
|
+
// The owner, signed in, reads the inbox as a collection (§5.2): the drain
|
|
815
|
+
// keeps it on the pod, under the owner's own rule. The token is only
|
|
816
|
+
// read, never verified, as at the outbox door: the pod checks it.
|
|
817
|
+
if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
|
|
818
|
+
const webid = claimedWebId(request);
|
|
819
|
+
if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
|
|
820
|
+
return { status: 303, headers: { ...doorCors, location: rec.podHome + 'ap/private/inbox', 'cache-control': 'no-store' }, body: '' };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
809
823
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
810
|
-
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
|
|
811
|
-
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(
|
|
824
|
+
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
|
|
825
|
+
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
|
|
812
826
|
const policy = await policyFor(rec, ctx.fetchImpl || fetch);
|
|
813
827
|
const standing = await accountState(ctx, up.handle, rec);
|
|
814
828
|
// A browser account's mail waits here while its app is closed (held-mail.mjs).
|
|
815
829
|
const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
|
|
816
830
|
const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
817
831
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
818
|
-
fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
832
|
+
fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
|
|
819
833
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
820
834
|
console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
|
|
821
835
|
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
@@ -827,9 +841,15 @@ async function route(request, ctx) {
|
|
|
827
841
|
const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
|
|
828
842
|
.catch((e) => ({ placed: false, why: e.message }));
|
|
829
843
|
console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
|
|
844
|
+
// Placed means written on the pod: the edge's copies of the category's
|
|
845
|
+
// and the forum's documents go, so the page that posted reads it back.
|
|
846
|
+
if (placed.placed) {
|
|
847
|
+
const tags = [`u-${up.handle}`, ...(placed.forum && placed.forum !== up.handle ? [`u-${placed.forum}`] : [])];
|
|
848
|
+
await ctx.purge?.(tags).catch((e) => console.log(`purge @${up.handle}: ${e?.message || e}`));
|
|
849
|
+
}
|
|
830
850
|
if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
|
|
831
851
|
}
|
|
832
|
-
return { status, headers:
|
|
852
|
+
return { status, headers: doorCors, body: '' };
|
|
833
853
|
}
|
|
834
854
|
|
|
835
855
|
// Outbox: the owner's own post, from any client that speaks ActivityPub
|
|
@@ -901,10 +921,9 @@ async function route(request, ctx) {
|
|
|
901
921
|
const podTarget = rec.podHome + up.rest;
|
|
902
922
|
// The owner's own documents — the full outbox, the liked list, the pending
|
|
903
923
|
// lists — are read at the pod, which decides who may; never held here,
|
|
904
|
-
// where one reader's answer would be served to the next.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
924
|
+
// where one reader's answer would be served to the next. A signed read of
|
|
925
|
+
// a private post by a server it was sent to is answered (private-read.mjs).
|
|
926
|
+
if (up.rest.startsWith('ap/private/')) return answerPrivateRead(ctx, rec, { request, handle: up.handle, podTarget, base, open, swap });
|
|
908
927
|
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
909
928
|
// the id rewrite below turns media links onto the front like every other
|
|
910
929
|
// pod url in a document. Answer those by pointing at the pod: bytes are not
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// front-pages.mjs — the front's pages and the files they load, read from
|
|
2
2
|
// beside this package: the opt-in page, the roster, the notices page, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// feeds answers 404.
|
|
3
|
+
// root page, each page's own script and the sign-in library. A file that is
|
|
4
|
+
// not there comes back null, and the route it feeds answers 404.
|
|
6
5
|
|
|
7
6
|
import fs from 'node:fs';
|
|
8
7
|
import path from 'node:path';
|
|
@@ -26,6 +25,5 @@ export function frontPages() {
|
|
|
26
25
|
noticesPage: readOrNull('notices.html'),
|
|
27
26
|
authBundle: readOrNull('solid-oidc-client.js'),
|
|
28
27
|
pageScripts,
|
|
29
|
-
installScript: readOrNull('install.sh'),
|
|
30
28
|
};
|
|
31
29
|
}
|
|
@@ -91,12 +91,34 @@ export function notifies(activity, ident) {
|
|
|
91
91
|
// Returns { status, reason } — the adapter turns it into an HTTP response —
|
|
92
92
|
// and, for a delivery that reached the pod, `content` (whether it was
|
|
93
93
|
// content rather than control) and `bytes`, so the caller can keep count.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
// The request as the sender signed it. A sender signs the host it delivered
|
|
95
|
+
// to and digests the bytes it sent; what reaches this code has been through
|
|
96
|
+
// an edge that may have rewritten the Host header, the URL's host or both
|
|
97
|
+
// (Netlify does), and a verifier that trusts those fails every honest
|
|
98
|
+
// delivery. So the verifier gets a request rebuilt from the bytes read here,
|
|
99
|
+
// at the door's own origin when the caller names it, else at the host a
|
|
100
|
+
// proxy says it forwarded for, else at the URL's own.
|
|
101
|
+
export function asSigned(request, bytes, origin) {
|
|
102
|
+
const u = new URL(request.url);
|
|
103
|
+
let host = null;
|
|
104
|
+
if (origin) { try { const o = new URL(origin); u.protocol = o.protocol; host = o.host; } catch { /* not an origin: the URL's own */ } }
|
|
105
|
+
host = host || request.headers.get('x-forwarded-host')?.split(',')[0].trim() || u.host;
|
|
106
|
+
u.host = host;
|
|
107
|
+
const headers = new Headers(request.headers);
|
|
108
|
+
headers.set('host', host);
|
|
109
|
+
// A GET or HEAD carries no body, and a Request refuses one (a signed read: private-read.mjs).
|
|
110
|
+
const bodiless = request.method === 'GET' || request.method === 'HEAD';
|
|
111
|
+
return new Request(u.href, { method: request.method, headers, ...(bodiless ? {} : { body: bytes }) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `origin` is the door's own, the one senders address (see asSigned).
|
|
115
|
+
export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null } = {}) {
|
|
116
|
+
// The body, once, as the bytes the sender digested; the text of it is what
|
|
117
|
+
// is filed and hashed, as before.
|
|
118
|
+
let bytes, raw;
|
|
119
|
+
try { bytes = new Uint8Array(await request.clone().arrayBuffer()); raw = new TextDecoder().decode(bytes); }
|
|
120
|
+
catch { return { status: 400, reason: 'unreadable body' }; }
|
|
121
|
+
if (bytes.byteLength > maxBytes) return { status: 413, reason: 'too large' };
|
|
100
122
|
|
|
101
123
|
let activity;
|
|
102
124
|
try { activity = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
|
|
@@ -114,15 +136,25 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
114
136
|
|
|
115
137
|
// The sender's key, kept between deliveries: a server pushing a hundred
|
|
116
138
|
// items is asked for its key once (caches.mjs).
|
|
117
|
-
const v = await verifyHttpSignature(request, {
|
|
139
|
+
const v = await verifyHttpSignature(asSigned(request, bytes, origin), {
|
|
118
140
|
documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
|
|
119
141
|
});
|
|
120
|
-
// A
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
142
|
+
// A signature that does not verify is NOT dropped (it was, 2026-08-20 to
|
|
143
|
+
// 2026-09-28, and for a stretch of that every honest Mastodon delivery was
|
|
144
|
+
// dropped with it, silently, because of what the edge had done to the
|
|
145
|
+
// request). It forwards unverified, like an absent or unfetchable-key
|
|
146
|
+
// signature, and the drain's verify-by-dereference decides; the log says
|
|
147
|
+
// why it failed and what the request looked like, so the next such
|
|
148
|
+
// stretch is visible.
|
|
149
|
+
let failed = null;
|
|
124
150
|
if (v.verified === false && v.reason === 'bad-signature') {
|
|
125
|
-
|
|
151
|
+
const has = (h) => (request.headers.has(h) ? 'yes' : 'no');
|
|
152
|
+
const fwd = request.headers.get('x-forwarded-host');
|
|
153
|
+
// The sender's signature parameters — which components it signed, its
|
|
154
|
+
// key id and algorithm — without the signature value itself.
|
|
155
|
+
const params = request.headers.get('signature-input')
|
|
156
|
+
|| (request.headers.get('signature') || '').replace(/signature="[^"]*"/u, 'signature="…"');
|
|
157
|
+
failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
|
|
126
158
|
}
|
|
127
159
|
|
|
128
160
|
// No per-user secret on record = no receipt: the delivery still filters and
|
|
@@ -137,8 +169,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
137
169
|
// holding any state.
|
|
138
170
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
139
171
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
140
|
-
return { status: 202, reason: v.verified ? 'verified' :
|
|
141
|
-
type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
172
|
+
return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
|
|
173
|
+
content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -171,6 +171,13 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
|
|
|
171
171
|
if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
|
|
172
172
|
await ctx.putDirectory(handle, next);
|
|
173
173
|
console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
|
|
174
|
+
// A forum's own row turned on starts its first run now: the run makes the
|
|
175
|
+
// copies, mints the keys a new forum has none of, and publishes its actors,
|
|
176
|
+
// so there is a forum to read before anyone posts to it. Its categories'
|
|
177
|
+
// rows are turned on before it (setup.mjs in the forum package).
|
|
178
|
+
if (on && next.kind === 'application' && ctx.forum?.isForumRow(next) && ctx.startKeeper) {
|
|
179
|
+
await ctx.startKeeper(handle).catch((e) => console.log(`keeper @${handle}: not started (${e.message})`));
|
|
180
|
+
}
|
|
174
181
|
return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
|
|
175
182
|
}
|
|
176
183
|
|
package/lib/gateway/httpsig.mjs
CHANGED
|
@@ -49,14 +49,16 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
|
|
|
49
49
|
export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
|
|
50
50
|
const hadSig = request.headers.get('signature') != null;
|
|
51
51
|
let result = null;
|
|
52
|
+
let threw = null;
|
|
52
53
|
try {
|
|
53
54
|
result = await verifyRequestDetailed(request, {
|
|
54
55
|
documentLoader,
|
|
55
56
|
...(keyCache ? { keyCache } : {}),
|
|
56
57
|
timeWindow: timeWindow ?? { hours: 1 },
|
|
57
58
|
});
|
|
58
|
-
} catch {
|
|
59
|
+
} catch (e) {
|
|
59
60
|
result = null; // the library itself failed — no more informative than a bad signature
|
|
61
|
+
threw = e?.message || String(e);
|
|
60
62
|
}
|
|
61
63
|
// Which signature the sender used: RFC 9421 carries a Signature-Input
|
|
62
64
|
// header beside Signature; the cavage draft carries Signature alone. The
|
|
@@ -83,9 +85,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
83
85
|
reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
|
|
84
86
|
};
|
|
85
87
|
}
|
|
88
|
+
// `why` is for the log line, not the receipt: the library's own word for
|
|
89
|
+
// the failure, or that it threw.
|
|
86
90
|
return {
|
|
87
91
|
verified: false, method, keyId: null, actor: null,
|
|
88
92
|
reason: 'bad-signature', checks: { signature: false },
|
|
93
|
+
why: threw ? `library threw: ${threw.split('\n')[0].slice(0, 120)}` : (kind || 'unknown'),
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// private-read.mjs — a followers-only or direct post, read at its address by
|
|
2
|
+
// a server it was sent to (ActivityPub §3.2). The read is signed the way a
|
|
3
|
+
// delivery is; the signer is resolved; and the post is served only to an
|
|
4
|
+
// actor in its audience: a follower for a post sent to followers, a named
|
|
5
|
+
// recipient for a direct one. That is the rule Mastodon applies to its own
|
|
6
|
+
// posts (StatusPolicy#show?). Anyone else learns nothing — 404, as for a
|
|
7
|
+
// document that is not there, which §3.2 allows for a private target.
|
|
8
|
+
//
|
|
9
|
+
// At fedipod.net this works for an account it keeps: the post is read from
|
|
10
|
+
// the pod with the keeper's credential, and the followers list from the
|
|
11
|
+
// account's copy. An account it does not keep gives it nothing to read with,
|
|
12
|
+
// so that read is sent on to the pod, as every private read was.
|
|
13
|
+
|
|
14
|
+
import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
|
|
15
|
+
import { asSigned } from './gateway-core.mjs';
|
|
16
|
+
import { senderKeys } from './caches.mjs';
|
|
17
|
+
import { keptNow } from './copy.mjs';
|
|
18
|
+
|
|
19
|
+
const AP_CT = 'application/activity+json';
|
|
20
|
+
const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
|
|
21
|
+
const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v])
|
|
22
|
+
.map((x) => (typeof x === 'string' ? x : x?.id)).filter((x) => typeof x === 'string');
|
|
23
|
+
|
|
24
|
+
/** Who a document was sent to: the actors named, and whether followers were. */
|
|
25
|
+
export function audienceOf(doc, followersUrls = []) {
|
|
26
|
+
const o = doc?.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
|
|
27
|
+
const named = new Set(['to', 'cc', 'audience'].flatMap((k) => [...list(doc?.[k]), ...list(o?.[k])]));
|
|
28
|
+
return {
|
|
29
|
+
named,
|
|
30
|
+
isPublic: [...named].some((a) => PUBLIC_NAMES.has(a)),
|
|
31
|
+
toFollowers: followersUrls.some((f) => named.has(f)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `actor` may read `doc`: named in its audience, or a follower when it went to followers. */
|
|
36
|
+
export function mayRead(doc, actor, { followersUrls = [], followers = [] } = {}) {
|
|
37
|
+
if (typeof actor !== 'string' || !actor) return false;
|
|
38
|
+
const a = audienceOf(doc, followersUrls);
|
|
39
|
+
if (a.isPublic || a.named.has(actor)) return true;
|
|
40
|
+
return a.toFollowers && followers.includes(actor);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The actor behind a signed read, or null with why. `origin` is the door's
|
|
45
|
+
* own (see asSigned in gateway-core.mjs); a GET has no body to digest.
|
|
46
|
+
*/
|
|
47
|
+
export async function signedReader(request, { fetchImpl = fetch, origin = null } = {}) {
|
|
48
|
+
if (!request.headers.get('signature')) return { actor: null, reason: 'no-signature' };
|
|
49
|
+
const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
|
|
50
|
+
documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
|
|
51
|
+
});
|
|
52
|
+
return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Refused reads are told nothing, and never held at the edge.
|
|
56
|
+
const nothing = (open) => ({ status: 404, headers: { ...open, 'cache-control': 'no-store', vary: 'Signature' }, body: '' });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A read under `ap/private/` at the front. Unsigned: sent on to the pod, which
|
|
60
|
+
* decides (the owner reads there with their own credential). Signed: answered
|
|
61
|
+
* here for a kept account, to a reader the post was sent to.
|
|
62
|
+
*/
|
|
63
|
+
export async function answerPrivateRead(ctx, rec, { request, handle, podTarget, base, open, swap }) {
|
|
64
|
+
if (!request.headers.get('signature')) {
|
|
65
|
+
return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
|
|
66
|
+
}
|
|
67
|
+
const say = (what) => console.log(`private read @${handle}: ${what}`);
|
|
68
|
+
if (!keptNow(ctx, rec) || typeof ctx.keeperFetch !== 'function' || !ctx.copyKv) { say('refused — account not kept here'); return nothing(open); }
|
|
69
|
+
const { actor, reason } = await signedReader(request, { fetchImpl: ctx.fetchImpl || fetch, origin: ctx.frontOrigin || null });
|
|
70
|
+
if (!actor) { say(`refused — ${reason}`); return nothing(open); }
|
|
71
|
+
const podFetch = await ctx.keeperFetch();
|
|
72
|
+
if (!podFetch) { say('refused — no keeper credential'); return nothing(open); }
|
|
73
|
+
let text = null;
|
|
74
|
+
try {
|
|
75
|
+
const res = await podFetch(podTarget, { headers: { accept: AP_CT } });
|
|
76
|
+
if (res.status === 200) text = await res.text();
|
|
77
|
+
} catch { /* unreachable: nothing to serve */ }
|
|
78
|
+
let doc = null;
|
|
79
|
+
try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
|
|
80
|
+
if (!doc) { say(`refused — the pod did not give it (${actor})`); return nothing(open); }
|
|
81
|
+
let followers = [];
|
|
82
|
+
try {
|
|
83
|
+
const got = await ctx.copyKv.get(`${handle}/contacts.json`);
|
|
84
|
+
followers = (JSON.parse(got?.text || '{}').followers || []).map((f) => f?.actor).filter(Boolean);
|
|
85
|
+
} catch { followers = []; }
|
|
86
|
+
const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
|
|
87
|
+
if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
|
|
88
|
+
say(`served to ${actor}`);
|
|
89
|
+
return { status: 200, headers: { ...open, 'content-type': AP_CT, 'cache-control': 'no-store', vary: 'Signature' },
|
|
90
|
+
body: request.method === 'HEAD' ? '' : swap(text, rec.podHome, base) };
|
|
91
|
+
}
|
package/lib/pod/urls.mjs
CHANGED
|
@@ -52,6 +52,9 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
|
|
|
52
52
|
// reads them there, with a credential the pod checks.
|
|
53
53
|
ownOutbox: home + 'ap/private/outbox',
|
|
54
54
|
liked: home + 'ap/private/liked',
|
|
55
|
+
// The inbox as its owner reads it (§5.2): what the account received,
|
|
56
|
+
// kept by the drain as a paged collection beside the archive.
|
|
57
|
+
ownInbox: home + 'ap/private/inbox',
|
|
55
58
|
profileHtml: face + 'ap/profile.html',
|
|
56
59
|
// Media stays on the pod even when fronted: attachment urls are not
|
|
57
60
|
// identity-checked by remotes, and proxying blobs would be pure cost.
|