fedipod 1.44.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -13
- package/architecture.md +5 -2
- package/gateway.md +56 -24
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +23 -74
- package/lib/client/masto/index.mjs +5 -4
- package/lib/client/masto/render.mjs +4 -1
- package/lib/core/intake/activities.mjs +8 -9
- package/lib/core/intake/counts.mjs +143 -0
- package/lib/core/intake/index.mjs +12 -1
- package/lib/core/intake/received.mjs +78 -0
- package/lib/core/publisher/notes.mjs +4 -1
- package/lib/core/social.mjs +2 -0
- package/lib/core/storage.mjs +54 -5
- package/lib/core/wire.mjs +16 -5
- package/lib/gateway/account-agent.mjs +20 -4
- package/lib/gateway/copy.mjs +95 -5
- package/lib/gateway/front-core.mjs +18 -9
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +98 -0
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/lib/surface/routes/owner.mjs +2 -2
- package/package.json +2 -2
- package/web/admin/gateway.js +8 -24
- package/web/admin/index.html +0 -16
- package/web/admin/record.js +1 -2
- package/web/admin/upkeep.js +1 -5
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +27 -10
- package/web/app/copy-mode.mjs +98 -13
- package/web/app/dist/boot.js +12 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +582 -282
- package/web/app/dist/sw.js.map +4 -4
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,7 @@ import * as wire from '../wire.mjs';
|
|
|
7
7
|
import * as wireQuotes from '../wire-quotes.mjs';
|
|
8
8
|
import * as containers from '../../pod/containers.mjs';
|
|
9
9
|
import * as podNotes from '../../pod/notes.mjs';
|
|
10
|
+
import { totalsFor } from '../intake/counts.mjs';
|
|
10
11
|
|
|
11
12
|
// Media container on the remote pod — public-Read like notes, created lazily
|
|
12
13
|
// at first upload (idempotent; the flag only saves round-trips).
|
|
@@ -177,7 +178,7 @@ export async function noteToSelf(publisher, text) {
|
|
|
177
178
|
// What the object is, who made it and when, where it lives and who it is
|
|
178
179
|
// addressed to stay as they are, and so do a poll's choices and its count.
|
|
179
180
|
const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
|
|
180
|
-
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
|
|
181
|
+
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies', 'likes', 'shares']);
|
|
181
182
|
export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
|
|
182
183
|
const { urls } = publisher;
|
|
183
184
|
const doc = await podNotes.read(publisher.remote, s.noteId);
|
|
@@ -485,6 +486,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
485
486
|
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
486
487
|
also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
|
|
487
488
|
quote: rowQuote(s),
|
|
489
|
+
...totalsFor(publisher.store, s.noteId),
|
|
488
490
|
});
|
|
489
491
|
await podNotes.write(publisher.remote, note.id, note);
|
|
490
492
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
@@ -537,6 +539,7 @@ async function restateNote(publisher, s, { quote }) {
|
|
|
537
539
|
summary: s.spoiler || null, sensitive: !!s.sensitive, updated: s.editedAt || null, container,
|
|
538
540
|
also: [...(reply ? [reply.actor] : []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])],
|
|
539
541
|
quote,
|
|
542
|
+
...totalsFor(publisher.store, s.noteId),
|
|
540
543
|
});
|
|
541
544
|
await podNotes.write(publisher.remote, note.id, note);
|
|
542
545
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
package/lib/core/social.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the activity and mirrors state into the store; the store keeps the
|
|
4
4
|
// activity a later Undo must name.
|
|
5
5
|
|
|
6
|
+
import { forgetCounts } from './intake/counts.mjs';
|
|
6
7
|
import crypto from 'node:crypto';
|
|
7
8
|
import * as wire from './wire.mjs';
|
|
8
9
|
import * as podNotes from '../pod/notes.mjs';
|
|
@@ -424,6 +425,7 @@ export async function deleteNote(agent, s) {
|
|
|
424
425
|
}
|
|
425
426
|
// Empty, and referenced by nothing once the note is gone.
|
|
426
427
|
await podNotes.dropReplies(agent.remote, wire.repliesId(s.noteId));
|
|
428
|
+
forgetCounts(agent.store, s.noteId);
|
|
427
429
|
|
|
428
430
|
// The Bluesky mirror goes with the post. Best-effort: a mirror the far side
|
|
429
431
|
// already lost is not a reason to keep the post here.
|
package/lib/core/storage.mjs
CHANGED
|
@@ -177,21 +177,31 @@ export { podOnly };
|
|
|
177
177
|
* An account's state kept at its gateway, reached through the gateway's state
|
|
178
178
|
* API (lib/gateway/state-api.mjs) with the token it handed out. `holder` is
|
|
179
179
|
* this agent's lease id: the gateway takes a write only from the lease's
|
|
180
|
-
* holder, and `onRefused` hears when it would not.
|
|
181
|
-
*
|
|
180
|
+
* holder, and `onRefused` hears when it would not. `onGone` hears when the
|
|
181
|
+
* gateway keeps no copy of the account any more and will make none. What
|
|
182
|
+
* stays on the pod is read and written on `pod`.
|
|
182
183
|
*/
|
|
183
184
|
export class StateApiStorage {
|
|
184
|
-
constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null }) {
|
|
185
|
+
constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null, onGone = null }) {
|
|
185
186
|
this.base = slash(base);
|
|
186
187
|
this.fetchImpl = fetchImpl;
|
|
187
188
|
this.token = token;
|
|
188
189
|
this.holder = holder;
|
|
189
190
|
this.pod = pod;
|
|
190
191
|
this.onRefused = onRefused;
|
|
192
|
+
this.onGone = onGone;
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
get kind() { return 'copy'; }
|
|
194
196
|
|
|
197
|
+
async _gone(res) {
|
|
198
|
+
if (res.status !== 404 || !this.onGone) return false;
|
|
199
|
+
const said = await res.clone().json().catch(() => null);
|
|
200
|
+
if (!said?.gone) return false;
|
|
201
|
+
this.onGone();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
195
205
|
_ask(p, init = {}) {
|
|
196
206
|
return this.fetchImpl(this.base + encodeURIComponent(p), {
|
|
197
207
|
...init, headers: { authorization: `Bearer ${this.token}`, ...(init.headers || {}) },
|
|
@@ -202,7 +212,7 @@ export class StateApiStorage {
|
|
|
202
212
|
if (sub) return { notModified: false, names: [], etag: null };
|
|
203
213
|
const res = await this._ask('', { headers: etag ? { 'if-none-match': etag } : {} });
|
|
204
214
|
if (res.status === 304) return { notModified: true, names: null, etag };
|
|
205
|
-
if (res.status >= 400) throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`);
|
|
215
|
+
if (res.status >= 400) { await this._gone(res); throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`); }
|
|
206
216
|
const { names } = await res.json();
|
|
207
217
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
208
218
|
}
|
|
@@ -211,7 +221,7 @@ export class StateApiStorage {
|
|
|
211
221
|
if (podOnly(p)) return this.pod ? this.pod.read(p, opts) : { ok: false, notModified: false, status: 404, body: null, etag: null };
|
|
212
222
|
const res = await this._ask(p, { headers: opts.etag ? { 'if-none-match': opts.etag } : {} });
|
|
213
223
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag: opts.etag };
|
|
214
|
-
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
|
224
|
+
if (res.status >= 400) { await this._gone(res); return { ok: false, notModified: false, status: res.status, body: null, etag: null }; }
|
|
215
225
|
return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
|
|
216
226
|
}
|
|
217
227
|
|
|
@@ -221,6 +231,7 @@ export class StateApiStorage {
|
|
|
221
231
|
const res = await this._ask(p, { method: 'PUT', headers: { 'content-type': contentType, 'x-fedipod-holder': this.holder }, body });
|
|
222
232
|
if (res.status < 400) return { ok: true, retry: false, why: '' };
|
|
223
233
|
if (res.status === 409) { this.onRefused?.(); return { ok: false, retry: false, why: 'another agent holds this account now', lost: true }; }
|
|
234
|
+
if (await this._gone(res)) return { ok: false, retry: false, why: 'the gateway keeps no copy of this account now', lost: true };
|
|
224
235
|
return { ok: false, retry: res.status >= 500 || res.status === 429, why: `HTTP ${res.status}`, retryAfterMs: 0 };
|
|
225
236
|
} catch (e) {
|
|
226
237
|
return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
|
|
@@ -232,11 +243,49 @@ export class StateApiStorage {
|
|
|
232
243
|
try {
|
|
233
244
|
const res = await this._ask(p, { method: 'DELETE', headers: { 'x-fedipod-holder': this.holder } });
|
|
234
245
|
if (res.status === 409) { this.onRefused?.(); return false; }
|
|
246
|
+
if (await this._gone(res)) return false;
|
|
235
247
|
return res.status < 400 || res.status === 404;
|
|
236
248
|
} catch { return false; }
|
|
237
249
|
}
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A pod's state container that more than one agent may act on: the owner's
|
|
254
|
+
* browser, and the gateway acting for an app while the hold is off. With a
|
|
255
|
+
* `fence` set, every write first asks it whether this agent still holds the
|
|
256
|
+
* account, and a "no" is refused and told to `onRefused`, as the gateway's
|
|
257
|
+
* copy refuses a write from anyone but its lease's holder. Without one, it is
|
|
258
|
+
* the storage it wraps.
|
|
259
|
+
*/
|
|
260
|
+
export class FencedStorage {
|
|
261
|
+
constructor(inner, { fence = null, onRefused = null } = {}) {
|
|
262
|
+
this.inner = inner;
|
|
263
|
+
this.fence = fence;
|
|
264
|
+
this.onRefused = onRefused;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get kind() { return this.inner.kind; }
|
|
268
|
+
get base() { return this.inner.base; }
|
|
269
|
+
list(sub, opts) { return this.inner.list(sub, opts); }
|
|
270
|
+
read(p, opts) { return this.inner.read(p, opts); }
|
|
271
|
+
|
|
272
|
+
async _refused() {
|
|
273
|
+
if (!this.fence || await this.fence().catch(() => true)) return false;
|
|
274
|
+
this.onRefused?.();
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async write(p, body, contentType) {
|
|
279
|
+
if (await this._refused()) return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
|
|
280
|
+
return this.inner.write(p, body, contentType);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async remove(p) {
|
|
284
|
+
if (await this._refused()) return false;
|
|
285
|
+
return this.inner.remove(p);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
240
289
|
// `file:` (or a bare path) is a directory; anything else is a pod.
|
|
241
290
|
export function storageFor(base, fetchImpl) {
|
|
242
291
|
return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);
|
package/lib/core/wire.mjs
CHANGED
|
@@ -70,13 +70,13 @@ export function jrd({ handle, host, actor, aliases = [], page = null }) {
|
|
|
70
70
|
// `kind: 'group'` publishes a Group rather than a Person, which is what makes
|
|
71
71
|
// Mastodon and Lemmy treat the actor as a community you join.
|
|
72
72
|
// Whether a follow will be held for the operator instead of accepted on
|
|
73
|
-
// arrival. A person's follows
|
|
74
|
-
//
|
|
75
|
-
//
|
|
73
|
+
// arrival. A person's follows are accepted unless `autoAcceptFollows: false`
|
|
74
|
+
// asks to see them; a group is open unless its operator asked to see joins.
|
|
75
|
+
// The actor document has to say
|
|
76
76
|
// the same thing, or a remote server shows "Following" for a follow that is
|
|
77
77
|
// in fact still waiting, and nothing ever tells it otherwise.
|
|
78
78
|
export function followsNeedApproval(config = {}) {
|
|
79
|
-
return !!config.approveJoins || (config.kind !== 'group' &&
|
|
79
|
+
return !!config.approveJoins || (config.kind !== 'group' && config.autoAcceptFollows === false);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
@@ -654,6 +654,16 @@ export function quotePolicy(visibility) {
|
|
|
654
654
|
return { canQuote: { automaticApproval: open ? [PUBLIC] : [], manualApproval: [] } };
|
|
655
655
|
}
|
|
656
656
|
|
|
657
|
+
// How many liked a post and how many boosted it (§5.7, §5.8): the totals,
|
|
658
|
+
// with nobody named. Both sections let a collection be filtered for whoever
|
|
659
|
+
// asks, and a total with no names is how Mastodon serves them too.
|
|
660
|
+
export function reactionCounts(likes = 0, shares = 0) {
|
|
661
|
+
return {
|
|
662
|
+
likes: { type: 'Collection', totalItems: Math.max(0, Number(likes) || 0) },
|
|
663
|
+
shares: { type: 'Collection', totalItems: Math.max(0, Number(shares) || 0) },
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
657
667
|
// `also`: actors addressed without being mentioned — the parent's author on
|
|
658
668
|
// a reply. In cc but not in tag, so their server receives the post and does
|
|
659
669
|
// not notify them. A direct post names its audience in the text alone.
|
|
@@ -661,7 +671,7 @@ export function quotePolicy(visibility) {
|
|
|
661
671
|
// authorization is the quoted author's QuoteAuthorization, once granted.
|
|
662
672
|
export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
|
|
663
673
|
visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [],
|
|
664
|
-
quote = null }) {
|
|
674
|
+
quote = null, likes = 0, shares = 0 }) {
|
|
665
675
|
const id = (container || urls.notes) + slug;
|
|
666
676
|
const who = mentions.map(m => m.actor);
|
|
667
677
|
const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
|
|
@@ -676,6 +686,7 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
|
|
|
676
686
|
to: addressed.to,
|
|
677
687
|
cc: addressed.cc,
|
|
678
688
|
replies: repliesId(id),
|
|
689
|
+
...reactionCounts(likes, shares),
|
|
679
690
|
interactionPolicy: quotePolicy(visibility),
|
|
680
691
|
};
|
|
681
692
|
if (quote?.id) {
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// It works with the gateway's own pod identity and nothing else, which the
|
|
6
6
|
// owner's app named in the rules on the account's folder. The account's state
|
|
7
|
-
// is its copy at the gateway when there is one (copy.mjs),
|
|
8
|
-
//
|
|
9
|
-
// kept, never made.
|
|
7
|
+
// is its copy at the gateway when there is one (copy.mjs), made from the pod
|
|
8
|
+
// while the hold is on, and the pod when the hold is off. The signing key is
|
|
9
|
+
// read from the pod when it is needed, never kept, never made.
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { PodTransport } from '../pod/transport.mjs';
|
|
12
12
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
@@ -19,7 +19,8 @@ import { Publisher } from '../core/publisher/index.mjs';
|
|
|
19
19
|
import { Intake } from '../core/intake/index.mjs';
|
|
20
20
|
import { C2S } from '../client/c2s.mjs';
|
|
21
21
|
import { keeperSession } from './keeper-session.mjs';
|
|
22
|
-
import { CopyStorage, copyMeta, copyLease, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
|
|
22
|
+
import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
|
|
23
|
+
import { makeCopy } from './state-api.mjs';
|
|
23
24
|
|
|
24
25
|
const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
25
26
|
|
|
@@ -58,6 +59,21 @@ export async function reachAccount(ctx, handle, rec, { log = console.log, sessio
|
|
|
58
59
|
return { remote, podFetch: (u, i) => remote.fetch(u, i), pod, root, urls: apUrls(pod, root), inCopy };
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Where this piece of work reads and writes the account, decided by a caller
|
|
64
|
+
* holding lockCopy, so the round cannot delete the copy in between: a copy
|
|
65
|
+
* that is here is used; none, while the hold is on, is made from the pod; with
|
|
66
|
+
* the hold off, the pod. Sets `at.inCopy`; returns { ok } or { ok: false,
|
|
67
|
+
* status, why }.
|
|
68
|
+
*/
|
|
69
|
+
export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
|
|
70
|
+
at.inCopy = !!(ctx.copyKv && await copyMeta(ctx.copyKv, handle));
|
|
71
|
+
if (at.inCopy || !ctx.copyKv || !holdOn(ctx)) return { ok: true };
|
|
72
|
+
const made = await makeCopy(ctx, handle, rec, log);
|
|
73
|
+
at.inCopy = made.ok;
|
|
74
|
+
return made;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* The account's state store and lease, as `holder`: over the copy when there
|
|
63
79
|
* is one, else over the pod.
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// While the gateway keeps an account running, the account's state documents
|
|
4
4
|
// (everything under ap-state/) live here, and every agent works from this copy:
|
|
5
5
|
// the owner's browser (through the state API, state-api.mjs), the keeper and
|
|
6
|
-
// the gateway's Mastodon API (in process).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// the gateway's Mastodon API (in process). What only the pod may hold never
|
|
7
|
+
// comes here: the signing key, the pod's own lease document, and the passwords
|
|
8
|
+
// and tokens of accounts on other servers.
|
|
9
|
+
//
|
|
10
|
+
// The hold: a person's copy is made from the pod when something needs it, and
|
|
11
|
+
// every fifteen minutes, whatever is happening, it is written to the pod and
|
|
12
|
+
// deleted (holdOver); the next use makes it again. So nothing private stays
|
|
13
|
+
// here longer than that. A gateway's admin may turn the hold off
|
|
14
|
+
// (FEDIPOD_HOLD=off): then no copy is made at all, and everything works on the
|
|
15
|
+
// pod directly. A forum's copy is kept all the time, and written to its pod
|
|
16
|
+
// every fifteen minutes (flushCopy), as before.
|
|
11
17
|
//
|
|
12
18
|
// The lease that decides which agent acts lives in the copy too, and the copy
|
|
13
19
|
// is what enforces it: a state document is written only by the lease's holder.
|
|
@@ -89,6 +95,30 @@ const readJson = async (kv, key) => {
|
|
|
89
95
|
export const keptNow = (ctx, rec) => !!(rec?.keeper && ctx.keeperWebId && (rec.keeper.webId || ctx.keeperWebId) === ctx.keeperWebId);
|
|
90
96
|
export const keptBefore = (ctx, rec) => !!(rec?.keeper?.webId && ctx.keeperWebId && rec.keeper.webId !== ctx.keeperWebId);
|
|
91
97
|
|
|
98
|
+
// A person's account, as against a forum or one of its categories.
|
|
99
|
+
export const isPersonal = (rec) => !!rec && (rec.kind || 'person') === 'person' && !rec.forum;
|
|
100
|
+
|
|
101
|
+
// The admin's setting: the hold is on unless FEDIPOD_HOLD says off.
|
|
102
|
+
export const holdFrom = (v) => !/^(off|no|false|0)$/iu.test(String(v ?? '').trim());
|
|
103
|
+
export const holdOn = (ctx) => ctx?.hold !== false;
|
|
104
|
+
|
|
105
|
+
// While the round writes a copy to the pod and deletes it: the state API asks
|
|
106
|
+
// its callers to try again in a moment, and the gateway's readers wait.
|
|
107
|
+
const CLOSING_MS = 2 * 60_000;
|
|
108
|
+
export async function closing(kv, h) {
|
|
109
|
+
const at = Number((await kv.get(`${account(h)}/closing`))?.text || 0);
|
|
110
|
+
return !!at && Date.now() - at < CLOSING_MS;
|
|
111
|
+
}
|
|
112
|
+
/** Wait, up to `waitMs`, for a copy being written and deleted to be gone. */
|
|
113
|
+
export async function waitOutClosing(kv, h, { waitMs = 10_000 } = {}) {
|
|
114
|
+
const until = Date.now() + waitMs;
|
|
115
|
+
while (await closing(kv, h)) {
|
|
116
|
+
if (Date.now() > until) return false;
|
|
117
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
92
122
|
/**
|
|
93
123
|
* Forget a copy its owner has written to the pod themselves: no write-back, no
|
|
94
124
|
* pod lease to free (the owner took it). Asked only by the lease's holder.
|
|
@@ -135,6 +165,12 @@ export function copyLease(kv, h, { id, log = () => {} }) {
|
|
|
135
165
|
return new Lease({ url: `copy:${account(h)}/lease`, fetchImpl: kvDocFetch(kv, `${h}/lease`), log, id });
|
|
136
166
|
}
|
|
137
167
|
|
|
168
|
+
/** Whether a browser, as against the gateway, holds the copy's lease now. */
|
|
169
|
+
export async function heldByBrowser(kv, h) {
|
|
170
|
+
const { doc } = await readJson(kv, `${account(h)}/lease`);
|
|
171
|
+
return !!doc && doc.holder !== GATEWAY_HOLDER && Date.now() < doc.expiresAt;
|
|
172
|
+
}
|
|
173
|
+
|
|
138
174
|
/** Whether `holder` holds the copy's lease now. */
|
|
139
175
|
export async function holds(kv, h, holder) {
|
|
140
176
|
const { doc } = await readJson(kv, `${account(h)}/lease`);
|
|
@@ -275,10 +311,14 @@ export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, l
|
|
|
275
311
|
}
|
|
276
312
|
const undo = async (why) => {
|
|
277
313
|
for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
|
|
314
|
+
await kv.delete(`_copies/${h}`).catch(() => {});
|
|
278
315
|
await podLease.release().catch(() => {});
|
|
279
316
|
return { ok: false, why };
|
|
280
317
|
};
|
|
281
318
|
try {
|
|
319
|
+
// Listed from the start, so a making cut off part way is found by the
|
|
320
|
+
// round and its documents deleted (clearLeftover).
|
|
321
|
+
await kv.set(`_copies/${h}`, String(Date.now()));
|
|
282
322
|
// Whatever an interrupted give-up left behind is not this copy.
|
|
283
323
|
for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
|
|
284
324
|
const listing = await pod.list('');
|
|
@@ -388,3 +428,53 @@ export async function dropCopy(kv, h, { pod, podFetch, stateUrl, log = () => {}
|
|
|
388
428
|
log(`copy @${h}: given up; the pod holds everything`);
|
|
389
429
|
return { ok: true };
|
|
390
430
|
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The end of the hold, for a person's copy: everything written to the pod, the
|
|
434
|
+
* pod's lease freed, and the copy deleted. Unlike giving the copy up
|
|
435
|
+
* (dropCopy), the copy's lease stays, so the browser holding it goes on holding
|
|
436
|
+
* it in the copy made next. Asked by the round, which holds lockCopy. Writes
|
|
437
|
+
* that reach the state API meanwhile are asked to come again (closing), and
|
|
438
|
+
* the next one finds the copy made again from the pod. Returns { ok } or
|
|
439
|
+
* { ok: false, why }; nothing is deleted unless the pod has it all.
|
|
440
|
+
*/
|
|
441
|
+
export async function holdOver(kv, h, { pod, podFetch, stateUrl, log = () => {} }) {
|
|
442
|
+
const meta = await copyMeta(kv, h);
|
|
443
|
+
if (!meta) return { ok: true, already: true };
|
|
444
|
+
await kv.set(`${account(h)}/closing`, String(Date.now()));
|
|
445
|
+
try {
|
|
446
|
+
// Again, twice at most, for a write that landed while the first went: the
|
|
447
|
+
// marker stops any more coming.
|
|
448
|
+
let behind = [];
|
|
449
|
+
for (let i = 0; i < 3; i++) {
|
|
450
|
+
await flushCopy(kv, h, { pod, log });
|
|
451
|
+
const { doc: flushed } = await readJson(kv, `${h}/flushed`);
|
|
452
|
+
behind = (await kv.list(`${h}/d/`)).filter((b) => flushed?.[b.key.slice(`${h}/d/`.length)]?.etag !== b.etag);
|
|
453
|
+
if (!behind.length) break;
|
|
454
|
+
}
|
|
455
|
+
if (behind.length) return { ok: false, why: `${behind.length} document(s) could not be written to the pod` };
|
|
456
|
+
const podLease = new Lease({ url: (stateUrl || meta.stateUrl) + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
|
|
457
|
+
try { await podLease.write({ holder: POD_HOLDER, expiresAt: 0 }); } catch (e) {
|
|
458
|
+
return { ok: false, why: `the pod's lease could not be freed (${e.message})` };
|
|
459
|
+
}
|
|
460
|
+
// The copy is gone the moment its meta is. Its listing goes last, so a
|
|
461
|
+
// delete cut off part way is found and finished by the next round
|
|
462
|
+
// (clearLeftover).
|
|
463
|
+
await kv.delete(`${h}/meta`);
|
|
464
|
+
await kv.delete(`${h}/flushed`);
|
|
465
|
+
for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
|
|
466
|
+
await kv.delete(`_copies/${h}`);
|
|
467
|
+
log(`copy @${h}: written to the pod and deleted (the hold)`);
|
|
468
|
+
return { ok: true };
|
|
469
|
+
} finally { await kv.delete(`${h}/closing`).catch(() => {}); }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A copy listed with no meta is one whose delete was cut off: its documents go. */
|
|
473
|
+
export async function clearLeftover(kv, h, { log = () => {} } = {}) {
|
|
474
|
+
if (await copyMeta(kv, h)) return false;
|
|
475
|
+
const left = await kv.list(`${account(h)}/d/`);
|
|
476
|
+
for (const b of left) await kv.delete(b.key);
|
|
477
|
+
await kv.delete(`_copies/${h}`);
|
|
478
|
+
if (left.length) log(`copy @${h}: ${left.length} document(s) left by a cut-off delete, deleted`);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
@@ -23,11 +23,12 @@ import * as podRoot from '../pod/root.mjs';
|
|
|
23
23
|
import * as podPolicy from '../pod/policy.mjs';
|
|
24
24
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
25
25
|
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
|
|
26
|
-
import { holdsMail, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
|
|
26
|
+
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
|
|
27
27
|
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
|
|
@@ -810,20 +811,29 @@ async function route(request, ctx) {
|
|
|
810
811
|
'access-control-max-age': '86400',
|
|
811
812
|
};
|
|
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
|
+
}
|
|
813
823
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
814
824
|
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(doorCors); }
|
|
815
825
|
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
|
|
816
826
|
const policy = await policyFor(rec, ctx.fetchImpl || fetch);
|
|
817
827
|
const standing = await accountState(ctx, up.handle, rec);
|
|
818
|
-
// A browser account's mail waits here while its app is closed (held-mail.mjs).
|
|
819
|
-
const
|
|
828
|
+
// A browser account's mail waits here while its app is closed (held-mail.mjs), unless the hold is off.
|
|
829
|
+
const away = mayBeAway(ctx, rec) && !(await isPresent(ctx, up.handle)); const held = away && holdsMail(ctx, rec);
|
|
820
830
|
const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
821
831
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
822
|
-
fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
832
|
+
fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
|
|
823
833
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
824
834
|
console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
|
|
825
835
|
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
826
|
-
if (
|
|
836
|
+
if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
|
|
827
837
|
// A forum fedipod.net keeps: the delivery is placed now, in this request,
|
|
828
838
|
// and the run that carries it is started. Whatever could not be placed
|
|
829
839
|
// here is still in the inbox for that run.
|
|
@@ -911,10 +921,9 @@ async function route(request, ctx) {
|
|
|
911
921
|
const podTarget = rec.podHome + up.rest;
|
|
912
922
|
// The owner's own documents — the full outbox, the liked list, the pending
|
|
913
923
|
// lists — are read at the pod, which decides who may; never held here,
|
|
914
|
-
// where one reader's answer would be served to the next.
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
}
|
|
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 });
|
|
918
927
|
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
919
928
|
// the id rewrite below turns media links onto the front like every other
|
|
920
929
|
// pod url in a document. Answer those by pointing at the pod: bytes are not
|
|
@@ -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.
|