fedipod 1.50.0 → 1.55.2
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 +2 -1
- package/gateway.md +25 -0
- package/gui.md +11 -3
- package/lib/client/masto/render.mjs +11 -4
- package/lib/client/masto/timelines.mjs +5 -0
- package/lib/connections/acctfeed.mjs +4 -1
- package/lib/connections/tagfeed.mjs +4 -1
- package/lib/core/graphview.mjs +9 -1
- package/lib/core/intake/index.mjs +35 -2
- package/lib/core/intake/verify.mjs +3 -1
- package/lib/core/pod-only.mjs +4 -2
- package/lib/core/publisher/index.mjs +19 -5
- package/lib/core/publisher/restore.mjs +1 -0
- package/lib/core/store.mjs +27 -6
- package/lib/gateway/account-agent.mjs +11 -5
- package/lib/gateway/check-letter.mjs +123 -0
- package/lib/gateway/connected-feeds.mjs +116 -0
- package/lib/gateway/copy.mjs +19 -0
- package/lib/gateway/front-core.mjs +5 -3
- package/lib/gateway/held-mail.mjs +25 -1
- package/lib/gateway/keeper.mjs +2 -1
- package/lib/gateway/masto-gateway.mjs +47 -7
- package/lib/surface/routes/owner.mjs +7 -4
- package/package.json +3 -3
- package/scripts/stage-site.mjs +2 -3
- package/vendor/sanitize-html.cjs +358 -171
- package/web/admin/bar.js +45 -0
- package/web/admin/group.js +17 -0
- package/web/admin/index.html +12 -8
- package/web/admin/record.js +19 -41
- package/web/admin/upkeep.js +3 -0
- package/web/app/README.md +3 -3
- package/web/app/admin-facade.mjs +19 -16
- package/web/app/agent.mjs +68 -3
- package/web/app/atproto-browser.mjs +28 -22
- package/web/app/deliver-relay.mjs +2 -1
- package/web/app/dist/boot.js +1 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +601 -273
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +32 -30
- package/web/app/idb-kv.mjs +4 -4
- package/web/app/shims/prelude.js +1 -0
- package/web/app/sw-src.mjs +44 -3
package/README.md
CHANGED
|
@@ -52,7 +52,8 @@ Akkoma, Pixelfed or Friendica, connect it from the manage page and read it
|
|
|
52
52
|
here. Its home timeline and notifications join the feed you already read,
|
|
53
53
|
interleaved by time, and a post both accounts see appears once. Favouriting,
|
|
54
54
|
boosting and replying act as the account the post came through. The token it
|
|
55
|
-
hands back
|
|
55
|
+
hands back is kept on your pod, so the account follows you to any browser you
|
|
56
|
+
sign in from.
|
|
56
57
|
|
|
57
58
|
**Moving in.** Mastodon-format exports of follows, blocks, mutes, lists and
|
|
58
59
|
domain blocks import from the manage page. Your old account can be listed as
|
package/gateway.md
CHANGED
|
@@ -240,6 +240,19 @@ An app checks for new posts every minute or so while it is open; there are no
|
|
|
240
240
|
live updates. Accounts are made on the fedipod.net front page, with a pod, not
|
|
241
241
|
from an app.
|
|
242
242
|
|
|
243
|
+
**Your connected accounts, with FediPod closed.** FediPod open in a browser
|
|
244
|
+
reads the Mastodon and Bluesky accounts you connected every five minutes.
|
|
245
|
+
While it is closed and an app is in use, fedipod.net reads them instead, at
|
|
246
|
+
most every five minutes, when the app checks your timeline or notifications.
|
|
247
|
+
Their logins are read from your pod for each read and are not kept at
|
|
248
|
+
fedipod.net. An account you have just connected can take up to an hour to be
|
|
249
|
+
read here.
|
|
250
|
+
|
|
251
|
+
**Hashtags.** The hashtags you follow are one list, in FediPod and in an app:
|
|
252
|
+
following or unfollowing one in either changes it in both. FediPod brings in
|
|
253
|
+
the posts for them, while it is open. **hashtag posts in home** on your manage
|
|
254
|
+
page keeps them out of your home timeline, in FediPod and in an app alike.
|
|
255
|
+
|
|
243
256
|
**Notifications on your phone.** An app that offers notifications can sign up
|
|
244
257
|
for them here. A mention, a reply, a direct message, a follow, or a like, boost
|
|
245
258
|
or reaction to your own post then reaches your phone as it arrives, whether
|
|
@@ -281,6 +294,18 @@ removes them at `/notices`, signed in as the admin the way the roster is.
|
|
|
281
294
|
A notice is a title and plain text: a blank line starts a paragraph, and a
|
|
282
295
|
web address becomes a link.
|
|
283
296
|
|
|
297
|
+
## When mail does not get through
|
|
298
|
+
|
|
299
|
+
When FediPod cannot read a piece of your mail, a red line across the top of
|
|
300
|
+
its pages says so until you open Show dead letters on your manage page. Your
|
|
301
|
+
phone hears too, at most once an hour, if a Mastodon app on it is signed in at
|
|
302
|
+
the gateway with notifications on.
|
|
303
|
+
|
|
304
|
+
The gateway's admin also gets a test letter every six hours, sent to their own
|
|
305
|
+
account through the same door every server uses. FediPod takes it in and shows
|
|
306
|
+
it nowhere. If FediPod has been open for an hour and the letter has not
|
|
307
|
+
arrived, the admin's phone is told and the red line goes up.
|
|
308
|
+
|
|
284
309
|
## What the gateway can see
|
|
285
310
|
|
|
286
311
|
It reads only public data to decide what concerns you: your published
|
package/gui.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
|
|
4
4
|
same page for your account. The rest of this page describes it as the
|
|
5
5
|
DeviceAgent serves it; the controls are the same, minus the manual inbox
|
|
6
|
-
drain
|
|
6
|
+
drain, which a browser does not have.
|
|
7
7
|
|
|
8
8
|
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
|
|
9
9
|
Picking an actor marked "(stopped)" starts its agent, then opens its page.
|
|
@@ -48,8 +48,7 @@ its own port.
|
|
|
48
48
|
|
|
49
49
|
Each identity has a page anyone can open, at `ap/profile.html` under its pod
|
|
50
50
|
— for example `https://your-pod.example/fedipod/ap/profile.html`
|
|
51
|
-
(the `fedipod` container is wherever you put it at setup
|
|
52
|
-
"stored at" row says where). It
|
|
51
|
+
(the `fedipod` container is wherever you put it at setup). It
|
|
53
52
|
shows the name, bio and address, and offers a Follow box: a visitor types
|
|
54
53
|
their own server and lands on that server's follow screen. Hand out that
|
|
55
54
|
link, or the `@name@host` address itself, which works in the search box of
|
|
@@ -96,6 +95,15 @@ Disconnecting stops the reading and removes the stored token; nothing already
|
|
|
96
95
|
posted is touched. If the other server stops accepting the token, the account
|
|
97
96
|
is marked **sign in again** rather than quietly dropped.
|
|
98
97
|
|
|
98
|
+
## Hashtag posts in your home timeline
|
|
99
|
+
|
|
100
|
+
The posts brought in for the hashtags you follow appear in your home timeline.
|
|
101
|
+
Set **hashtag posts in home** to *hidden* and they appear only on the public
|
|
102
|
+
timeline and on each hashtag's page; they are still brought in. A hashtag post
|
|
103
|
+
that one of your connected accounts also brought in is from someone you follow
|
|
104
|
+
there, and stays. An app already showing them keeps them on screen until it
|
|
105
|
+
reloads.
|
|
106
|
+
|
|
99
107
|
## Handling follow requests
|
|
100
108
|
|
|
101
109
|
New followers are accepted automatically. Set **new followers** to *wait for
|
|
@@ -10,6 +10,13 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire
|
|
|
10
10
|
import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
|
|
11
11
|
import { htmlToText } from './body.mjs';
|
|
12
12
|
|
|
13
|
+
// Posts stored from 2026-09-13 to 2026-10-10 hold a Mastodon post's text twice,
|
|
14
|
+
// joined by a comma; shown once.
|
|
15
|
+
export const once = (html) => {
|
|
16
|
+
const m = (html.length - 1) / 2;
|
|
17
|
+
return Number.isInteger(m) && html[m] === ',' && html.slice(0, m) === html.slice(m + 1) ? html.slice(0, m) : html;
|
|
18
|
+
};
|
|
19
|
+
|
|
13
20
|
// 1x1 transparent PNG — placeholder header, and the instance thumbnail. A
|
|
14
21
|
// banner nobody set should take up no room and show nothing; an avatar is a
|
|
15
22
|
// different matter (below).
|
|
@@ -273,7 +280,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
273
280
|
created_at: s.published || new Date().toISOString(),
|
|
274
281
|
in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
|
|
275
282
|
in_reply_to_account_id: null,
|
|
276
|
-
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
|
|
283
|
+
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: once(s.spoiler || ''),
|
|
277
284
|
visibility: s.visibility || 'public', language: null,
|
|
278
285
|
edited_at: s.editedAt || null,
|
|
279
286
|
uri: s.noteId, url: s.link || s.noteId,
|
|
@@ -284,7 +291,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
284
291
|
favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
|
|
285
292
|
reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
|
|
286
293
|
muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
|
|
287
|
-
content: s.content || '',
|
|
294
|
+
content: once(s.content || ''),
|
|
288
295
|
reblog: null, application: null,
|
|
289
296
|
account: api.account(s.actor),
|
|
290
297
|
media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
|
|
@@ -323,7 +330,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
323
330
|
// information it uses to decide anyway.
|
|
324
331
|
export function filtersFor(api, s) {
|
|
325
332
|
const now = Date.now();
|
|
326
|
-
const hay = `${s.content || ''} ${s.spoiler || ''}`
|
|
333
|
+
const hay = `${once(s.content || '')} ${once(s.spoiler || '')}`
|
|
327
334
|
.replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
|
|
328
335
|
.toLowerCase();
|
|
329
336
|
if (!hay.trim()) return [];
|
|
@@ -403,7 +410,7 @@ export function pushNotify(api, n) {
|
|
|
403
410
|
return api.push.notify(n, {
|
|
404
411
|
notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
|
|
405
412
|
title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
|
|
406
|
-
body: s ? htmlToText(s.content || '').slice(0, 140) : '',
|
|
413
|
+
body: s ? htmlToText(once(s.content || '')).slice(0, 140) : '',
|
|
407
414
|
icon: acct.avatar || '',
|
|
408
415
|
});
|
|
409
416
|
}
|
|
@@ -18,6 +18,10 @@ export async function handle(api, ctx) {
|
|
|
18
18
|
if (pathname === '/api/v1/timelines/home' || pathname === '/api/v1/timelines/public'
|
|
19
19
|
|| pathname === '/api/v1/trends/statuses') {
|
|
20
20
|
const localOnly = pathname === '/api/v1/timelines/public' && url.searchParams.get('local') === 'true';
|
|
21
|
+
// The owner can keep hashtag posts out of Home (`tagsInHome: false`); they
|
|
22
|
+
// stay on the public timeline, their hashtags' pages and trends. One a
|
|
23
|
+
// connected account also brought in is from someone followed there.
|
|
24
|
+
const hideTags = pathname === '/api/v1/timelines/home' && api.store.getConfig?.()?.tagsInHome === false;
|
|
21
25
|
// The UNFILTERED list, both as the source and as the reply-count corpus:
|
|
22
26
|
// a reply can be a mention, and mentions are filtered out of timelines
|
|
23
27
|
// below — counting against the filtered set would undercount them.
|
|
@@ -31,6 +35,7 @@ export async function handle(api, ctx) {
|
|
|
31
35
|
.filter(s => s.kind !== 'remote' && s.kind !== 'mention')
|
|
32
36
|
.filter(s => !s.direct && s.visibility !== 'direct')
|
|
33
37
|
.filter(s => !muted.has(s.actor) && !muted.has(s.via))
|
|
38
|
+
.filter(s => !hideTags || s.kind !== 'tag' || s.sourceAccts?.length)
|
|
34
39
|
.filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
|
|
35
40
|
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
36
41
|
const { items: page, headers } = api.page(items, url);
|
|
@@ -145,8 +145,11 @@ export class AcctFeed {
|
|
|
145
145
|
return map;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// A server that does not say what is left (not every Mastodon-API server
|
|
149
|
+
// does) is not taken to have nothing left.
|
|
148
150
|
_rateGuard(id, handle, res) {
|
|
149
|
-
const
|
|
151
|
+
const said = res.headers.get('x-ratelimit-remaining');
|
|
152
|
+
const left = said == null || said.trim() === '' ? NaN : Number(said);
|
|
150
153
|
if (!Number.isFinite(left) || left > RATE_FLOOR) return false;
|
|
151
154
|
const reset = Date.parse(res.headers.get('x-ratelimit-reset') || '');
|
|
152
155
|
const wait = Number.isFinite(reset) ? Math.max(0, reset - Date.now()) : BACKOFF_MIN_MS;
|
|
@@ -81,8 +81,11 @@ export class TagFeed {
|
|
|
81
81
|
if (Array.isArray(patch.tags)) clean.tags = patch.tags.map(t => String(t).replace(/^#/, '').trim()).filter(Boolean);
|
|
82
82
|
if (patch.intervalMin) clean.intervalMin = Math.max(5, Number(patch.intervalMin) || DEFAULTS.intervalMin);
|
|
83
83
|
this.store.write('tagfeed.json', { ...this.config(), ...clean });
|
|
84
|
+
// A feed that sweeps starts again on the new list; one that only answers
|
|
85
|
+
// for the list (fedipod.net, for an app) was never started and is not now.
|
|
86
|
+
const sweeping = this.stopped === false;
|
|
84
87
|
this.stop();
|
|
85
|
-
this.start();
|
|
88
|
+
if (sweeping) this.start();
|
|
86
89
|
return this.config();
|
|
87
90
|
}
|
|
88
91
|
|
package/lib/core/graphview.mjs
CHANGED
|
@@ -227,6 +227,8 @@ function fromLiteral(term) {
|
|
|
227
227
|
return term.value;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
const isTagged = (term) => term.termType === 'Literal' && !!term.language;
|
|
231
|
+
|
|
230
232
|
function toValue(term, ctx, depth, path) {
|
|
231
233
|
if (term.termType === 'Literal') return fromLiteral(term);
|
|
232
234
|
if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
|
|
@@ -248,8 +250,14 @@ function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
|
|
|
248
250
|
|
|
249
251
|
const term = TERMS.get(name);
|
|
250
252
|
if (!term) return undefined;
|
|
251
|
-
|
|
253
|
+
let values = preds.get(term.iri);
|
|
252
254
|
if (!values || !values.length) return undefined;
|
|
255
|
+
// `content` and `contentMap` are one predicate, and Mastodon sends a post's
|
|
256
|
+
// text both ways. The plain term is the untagged copy, else the first tagged.
|
|
257
|
+
if (values.some(isTagged)) {
|
|
258
|
+
const plain = values.filter(v => !isTagged(v));
|
|
259
|
+
values = plain.length ? plain : [values.find(isTagged)];
|
|
260
|
+
}
|
|
253
261
|
|
|
254
262
|
// `items` and `orderedItems` are the same predicate; the list is what tells
|
|
255
263
|
// them apart, so asking for the ordered one only answers when it is ordered.
|
|
@@ -151,9 +151,17 @@ export class Intake {
|
|
|
151
151
|
rec.last = String(message || '').slice(0, 200);
|
|
152
152
|
all[url] = rec;
|
|
153
153
|
this.store.write(ATTEMPTS_DOC, all);
|
|
154
|
+
if (rec.n === 2) this._trouble('failing', url, message);
|
|
154
155
|
return rec.n;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Mail that could not be read, as against mail refused: a second failed try,
|
|
159
|
+
// or a piece set aside after its last. `onTrouble` tells the owner.
|
|
160
|
+
_trouble(kind, url, reason) {
|
|
161
|
+
try { this.onTrouble?.({ kind, url, reason: String(reason || '').slice(0, 200), at: new Date().toISOString() }); }
|
|
162
|
+
catch { /* telling must never stop the drain */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
_clearAttempt(url) {
|
|
158
166
|
const all = this.store.read(ATTEMPTS_DOC, {});
|
|
159
167
|
if (!all[url]) return;
|
|
@@ -202,13 +210,25 @@ export class Intake {
|
|
|
202
210
|
this._drainAgain = false;
|
|
203
211
|
// Paced, not immediate: chained sweeps put a ceiling on work per unit
|
|
204
212
|
// time, so a delivery flood cannot run the drain back-to-back.
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
this._chainTimer = setTimeout(() => {
|
|
214
|
+
this._chainTimer = null;
|
|
215
|
+
this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
216
|
+
}, CHAIN_GAP_MS);
|
|
217
|
+
this._chainTimer.unref?.();
|
|
207
218
|
}
|
|
208
219
|
});
|
|
209
220
|
return this._draining;
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
// Resolves once no sweep is running and none is waiting to follow on, so a
|
|
224
|
+
// browser worker can stay up until the round it started is done.
|
|
225
|
+
async settled() {
|
|
226
|
+
while (this._draining || this._chainTimer) {
|
|
227
|
+
if (this._draining) await this._draining.catch(() => {});
|
|
228
|
+
else await new Promise((r) => setTimeout(r, 500));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
// Discard the content waiting in the inbox from before `before`, on the
|
|
213
233
|
// owner's say-so — the admin page asks, this does it. NOT a blind sweep:
|
|
214
234
|
// every item is read, because the type is the only thing that decides its
|
|
@@ -447,6 +467,7 @@ export class Intake {
|
|
|
447
467
|
this.log(`inbox batch ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
448
468
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
449
469
|
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: null });
|
|
470
|
+
this._trouble('set-aside', url, e.message);
|
|
450
471
|
pending.push(url);
|
|
451
472
|
}
|
|
452
473
|
}
|
|
@@ -489,6 +510,7 @@ export class Intake {
|
|
|
489
510
|
this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
490
511
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
491
512
|
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: trimActivity(at.activity) });
|
|
513
|
+
this._trouble('set-aside', url, e.message);
|
|
492
514
|
// The dead letter IS the record of this item — deleting before it is
|
|
493
515
|
// written down would lose the only evidence it ever arrived, so it
|
|
494
516
|
// goes through the same commit-then-delete batch as everything else.
|
|
@@ -522,6 +544,14 @@ export class Intake {
|
|
|
522
544
|
const read = raw ? await readLenient(raw) : null;
|
|
523
545
|
if (read?.degraded) this.log(`inbox item ${url} grounded to read: ${read.degraded}`);
|
|
524
546
|
const activity = at.activity = read?.view ?? read?.doc ?? null;
|
|
547
|
+
// The gateway's test letter (lib/gateway/check-letter.mjs): read this far
|
|
548
|
+
// like any mail, then answered, and shown nowhere. An answer that does
|
|
549
|
+
// not go leaves it to be tried again.
|
|
550
|
+
const from = typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id;
|
|
551
|
+
if (from && this.checkLetters?.actor && from === this.checkLetters.actor) {
|
|
552
|
+
await this.checkLetters.taken(String(activity.id || ''));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
525
555
|
// What the shapes make of it. This decides NOTHING — the activity is
|
|
526
556
|
// handled either way. It is written down so that which shapes fire on
|
|
527
557
|
// real traffic is a question with an answer, rather than a guess made
|
|
@@ -577,6 +607,7 @@ export class Intake {
|
|
|
577
607
|
const batch = await this._batchEntries(url);
|
|
578
608
|
if (!batch) {
|
|
579
609
|
this.store.addDeadLetter({ inboxUrl: url, reason: 'unreadable batch', activity: null });
|
|
610
|
+
this._trouble('set-aside', url, 'unreadable batch');
|
|
580
611
|
return { entries: 1, complete: true };
|
|
581
612
|
}
|
|
582
613
|
let complete = true;
|
|
@@ -596,6 +627,7 @@ export class Intake {
|
|
|
596
627
|
this.log(`inbox batch entry ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
|
|
597
628
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
598
629
|
this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
|
|
630
|
+
this._trouble('set-aside', key, err.message);
|
|
599
631
|
} else {
|
|
600
632
|
complete = false;
|
|
601
633
|
}
|
|
@@ -635,6 +667,7 @@ export class Intake {
|
|
|
635
667
|
this.log(`held delivery ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
|
|
636
668
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
637
669
|
this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
|
|
670
|
+
this._trouble('set-aside', key, err.message);
|
|
638
671
|
done.push(e.name);
|
|
639
672
|
}
|
|
640
673
|
}
|
|
@@ -138,8 +138,10 @@ export function receiptVouchesFor(intake, receipt, actor) {
|
|
|
138
138
|
// Delete is verified — the same verify-by-dereference the rest of intake uses.
|
|
139
139
|
export async function isGone(intake, url) {
|
|
140
140
|
let res;
|
|
141
|
+
// A deliverer that throws on a refusal (the browser's relay) still carries
|
|
142
|
+
// the origin's answer: 404 or 410 from the origin itself is gone.
|
|
141
143
|
try { res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
|
|
142
|
-
catch { return null; }
|
|
144
|
+
catch (e) { return e?.answered && (e.status === 404 || e.status === 410) ? true : null; }
|
|
143
145
|
if (res.status === 404 || res.status === 410) return true;
|
|
144
146
|
if (res.status < 400) {
|
|
145
147
|
// A Tombstone answers 200 and still means deleted.
|
package/lib/core/pod-only.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// pod-only.mjs — what stays on the pod, and only there, when an account works
|
|
2
2
|
// from a copy kept at its gateway (lib/gateway/copy.mjs): the signing key, the
|
|
3
|
-
// pod's own lease, and the passwords and tokens of accounts on other servers
|
|
4
|
-
|
|
3
|
+
// pod's own lease, and the passwords and tokens of accounts on other servers
|
|
4
|
+
// (`conn-*` as the vault names them, and the browser's two documents).
|
|
5
|
+
const CONNECTED = new Set(['fediaccts.json', 'atproto.json']);
|
|
6
|
+
export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-') || CONNECTED.has(name);
|
|
@@ -99,13 +99,26 @@ export class Publisher {
|
|
|
99
99
|
// `clientOrigin` is overridden only by the agent serving a client its own
|
|
100
100
|
// local actor: standalone, the published document cannot name a loopback
|
|
101
101
|
// address, but a client already talking to that address can be told it.
|
|
102
|
+
// Whether follows wait for the owner, from the setting as saved now: this
|
|
103
|
+
// config is a copy taken when the agent started, and the saved one may have
|
|
104
|
+
// changed since.
|
|
105
|
+
approvesByHand() {
|
|
106
|
+
const saved = this.store?.getConfig?.() || {};
|
|
107
|
+
return wire.followsNeedApproval({ ...this.config,
|
|
108
|
+
...('autoAcceptFollows' in saved ? { autoAcceptFollows: saved.autoAcceptFollows } : {}),
|
|
109
|
+
...('approveJoins' in saved ? { approveJoins: saved.approveJoins } : {}) });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Whether the profile last published says what the setting says.
|
|
113
|
+
profileAgrees() { return this.store.read('published.json', {}).approvesByHand === this.approvesByHand(); }
|
|
114
|
+
|
|
102
115
|
actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null,
|
|
103
116
|
clientOrigin = this.clientOrigin } = {}) {
|
|
104
117
|
const { urls } = this;
|
|
105
118
|
return wire.actorDoc({
|
|
106
119
|
urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
107
120
|
movedTo, kind: this.config.kind,
|
|
108
|
-
approveJoins:
|
|
121
|
+
approveJoins: this.approvesByHand(),
|
|
109
122
|
assertionKey: this.assertionKey,
|
|
110
123
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
111
124
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
@@ -283,10 +296,11 @@ export class Publisher {
|
|
|
283
296
|
// one the operator makes because the first did not work — was skipped as a
|
|
284
297
|
// no-op and reported success. The digest is a record of what is up there,
|
|
285
298
|
// and an unreachable document is not up there.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
299
|
+
// What the actor now says about followers is recorded whatever else was
|
|
300
|
+
// unreachable: it was written, and a start that finds it disagreeing
|
|
301
|
+
// writes it again (profileAgrees).
|
|
302
|
+
this.store.write('published.json', { ...this.store.read('published.json', {}),
|
|
303
|
+
...(!unreachable.length ? { surfaceDigest: surface } : {}), approvesByHand: !!actorDoc.manuallyApprovesFollowers });
|
|
290
304
|
// Named as the fediverse sees it: a fronted actor's name and host are the
|
|
291
305
|
// front's, not the pod's.
|
|
292
306
|
const pubName = wire.publicHandle(this.config);
|
|
@@ -210,6 +210,7 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
210
210
|
// merge-only rebuild when something is.
|
|
211
211
|
export async function healStatuses(publisher) {
|
|
212
212
|
const { urls, store } = publisher;
|
|
213
|
+
if (store.unread?.has('statuses.json')) return { missing: 0, recovered: 0 }; // nothing to compare with
|
|
213
214
|
const own = store.read('outbox.json', [])
|
|
214
215
|
.map(i => (typeof i === 'string' ? i : null))
|
|
215
216
|
.filter(id => id && id.startsWith(urls.notes));
|
package/lib/core/store.mjs
CHANGED
|
@@ -90,6 +90,10 @@ export class PodStore {
|
|
|
90
90
|
this._held = 0;
|
|
91
91
|
this.chain = Promise.resolve(); // serialized writes
|
|
92
92
|
this.verdicts = new Map(); // name → did its last write land? (read by commit)
|
|
93
|
+
// Documents the pod holds that could not be read, and that nothing here
|
|
94
|
+
// has a copy of. Never written to until they are read: what this store
|
|
95
|
+
// would send is not the document, and would replace it on the pod.
|
|
96
|
+
this.unread = new Set();
|
|
93
97
|
// Moves on each time queued writes are dropped (discardPending): work that
|
|
94
98
|
// began in an earlier generation has lost results, whatever a commit says.
|
|
95
99
|
this.generation = 0;
|
|
@@ -106,7 +110,7 @@ export class PodStore {
|
|
|
106
110
|
attach(storage) {
|
|
107
111
|
// A different tree is different state: carrying the old cache and its
|
|
108
112
|
// ETags across would serve one container's documents as another's.
|
|
109
|
-
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
|
|
113
|
+
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); this.unread.clear(); }
|
|
110
114
|
this.storage = storage;
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -137,8 +141,8 @@ export class PodStore {
|
|
|
137
141
|
if (listing.missing && this.storage.kind === 'pod') {
|
|
138
142
|
throw new Error(`state container missing (HTTP 404) at ${this.base} — pod unreachable or state gone`);
|
|
139
143
|
}
|
|
140
|
-
this.etags.set('', listing.etag);
|
|
141
144
|
const names = listing.names.filter(n => n.endsWith('.json'));
|
|
145
|
+
for (const name of this.unread) if (!names.includes(name)) this.unread.delete(name); // gone: nothing to write over
|
|
142
146
|
let fetched = 0;
|
|
143
147
|
const skipped = [];
|
|
144
148
|
for (const name of names) {
|
|
@@ -147,17 +151,20 @@ export class PodStore {
|
|
|
147
151
|
if (r.notModified) continue; // ours is current
|
|
148
152
|
if (!r.ok) {
|
|
149
153
|
// One unreadable document must not restart the whole sweep — skip it,
|
|
150
|
-
// keep whatever we already hold, and try again next load (
|
|
151
|
-
// recorded, so the
|
|
154
|
+
// keep whatever we already hold, and try again next load (neither its
|
|
155
|
+
// etag nor the container's is recorded, so the next load lists and
|
|
156
|
+
// reads it again). config.json is the
|
|
152
157
|
// exception: without it the caller cannot tell "never set up" from
|
|
153
158
|
// "could not read", and would tell the user to run setup.
|
|
154
159
|
if (name === 'config.json' && !this.cache.has(name)) {
|
|
155
160
|
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
156
161
|
}
|
|
162
|
+
if (!this.lastText.has(name)) this.unread.add(name);
|
|
157
163
|
skipped.push(`${name} (HTTP ${r.status})`);
|
|
158
164
|
continue;
|
|
159
165
|
}
|
|
160
166
|
fetched++;
|
|
167
|
+
this.unread.delete(name);
|
|
161
168
|
this.etags.set(name, r.etag);
|
|
162
169
|
try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
|
|
163
170
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
@@ -165,7 +172,12 @@ export class PodStore {
|
|
|
165
172
|
// Kept for /status: a document skipped here is a timeline or a contact
|
|
166
173
|
// list quietly missing, and the owner should be able to see that.
|
|
167
174
|
this.lastSkipped = skipped;
|
|
168
|
-
|
|
175
|
+
// The container's ETag vouches for every document in it, so it is kept
|
|
176
|
+
// only when every one was read; otherwise the next load would stop at
|
|
177
|
+
// "not modified" and never retry what was skipped.
|
|
178
|
+
if (skipped.length) this.etags.delete(''); else this.etags.set('', listing.etag);
|
|
179
|
+
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`
|
|
180
|
+
+ (this.unread.size ? ` — not written until read: ${[...this.unread].join(', ')}` : ''));
|
|
169
181
|
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
170
182
|
this.onLoaded?.();
|
|
171
183
|
}
|
|
@@ -177,6 +189,7 @@ export class PodStore {
|
|
|
177
189
|
for (const { name, text, etag } of docs) {
|
|
178
190
|
try { this.cache.set(name, JSON.parse(text)); } catch { continue; }
|
|
179
191
|
this.lastText.set(name, text);
|
|
192
|
+
this.unread.delete(name);
|
|
180
193
|
if (etag) this.etags.set(name, etag);
|
|
181
194
|
}
|
|
182
195
|
if (listingEtag) this.etags.set('', listingEtag);
|
|
@@ -193,11 +206,12 @@ export class PodStore {
|
|
|
193
206
|
const r = await this.storage.read(name, etag ? { etag } : undefined);
|
|
194
207
|
if (r.notModified) continue;
|
|
195
208
|
if (r.status === 404) {
|
|
196
|
-
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
|
|
209
|
+
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name); this.unread.delete(name);
|
|
197
210
|
} else if (!r.ok) {
|
|
198
211
|
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
199
212
|
} else {
|
|
200
213
|
this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); this.etags.set(name, r.etag);
|
|
214
|
+
this.unread.delete(name);
|
|
201
215
|
}
|
|
202
216
|
this.onSettled?.(name);
|
|
203
217
|
}
|
|
@@ -216,6 +230,8 @@ export class PodStore {
|
|
|
216
230
|
write(name, obj) {
|
|
217
231
|
this.cache.set(name, structuredClone(obj));
|
|
218
232
|
if (!this.storage) return;
|
|
233
|
+
// Not sent: the next commit says it did not land, and reads it first.
|
|
234
|
+
if (this.unread.has(name)) { this.verdicts.set(name, false); return; }
|
|
219
235
|
// The same bytes the pod already holds are not sent again. A like, a
|
|
220
236
|
// bookmark, a sweep that found nothing new each used to upload the whole
|
|
221
237
|
// timeline index and the whole people cache, unchanged — the largest
|
|
@@ -263,6 +279,7 @@ export class PodStore {
|
|
|
263
279
|
// the returned promise so one failure cannot poison the queue.
|
|
264
280
|
_put(name) {
|
|
265
281
|
const done = this.chain.then(async () => {
|
|
282
|
+
if (this.unread.has(name)) return false;
|
|
266
283
|
const body = serialise(this.cache.get(name));
|
|
267
284
|
await Promise.resolve(this.onWriting?.(name, body)).catch(() => {});
|
|
268
285
|
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
@@ -313,6 +330,10 @@ export class PodStore {
|
|
|
313
330
|
// `since`: the generation the caller's work began in. Anything dropped since
|
|
314
331
|
// makes the answer false.
|
|
315
332
|
async commit({ since } = {}) {
|
|
333
|
+
// A document that could not be read is read now, replacing whatever was
|
|
334
|
+
// written to it here; those writes already count as not landed, so the
|
|
335
|
+
// drain keeps its items and takes them again against the real document.
|
|
336
|
+
if (this.unread.size && this.storage) await this.refresh([...this.unread]).catch(() => {});
|
|
316
337
|
const pending = [];
|
|
317
338
|
// Held names too, or a sweep that suspended the debounce would delete the
|
|
318
339
|
// pod's copy of an item whose result had not been written down — the one
|
|
@@ -18,6 +18,7 @@ import { Deliverer } from '../core/deliver.mjs';
|
|
|
18
18
|
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
|
+
import { TagFeed } from '../connections/tagfeed.mjs';
|
|
21
22
|
import { keeperSession } from './keeper-session.mjs';
|
|
22
23
|
import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
|
|
23
24
|
import { makeCopy } from './state-api.mjs';
|
|
@@ -76,14 +77,15 @@ export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
|
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
79
|
* The account's state store and lease, as `holder`: over the copy when there
|
|
79
|
-
* is one, else over the pod.
|
|
80
|
+
* is one, else over the pod. `podNames`: what of the pod-only documents the
|
|
81
|
+
* store loads with the copy.
|
|
80
82
|
*/
|
|
81
|
-
export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER } = {}) {
|
|
83
|
+
export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER, podNames = ['keys.json'] } = {}) {
|
|
82
84
|
const podState = new HttpStorage(at.urls.state, at.podFetch);
|
|
83
85
|
if (at.inCopy) {
|
|
84
86
|
return {
|
|
85
|
-
// Of what stays on the pod, only the key: one read per piece of work.
|
|
86
|
-
store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames
|
|
87
|
+
// Of what stays on the pod, only the key, by default: one read per piece of work.
|
|
88
|
+
store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames }), log }),
|
|
87
89
|
lease: copyLease(ctx.copyKv, handle, { id: holder, log }),
|
|
88
90
|
};
|
|
89
91
|
}
|
|
@@ -99,7 +101,7 @@ export function stateAndLease(ctx, handle, at, { log = console.log, holder = GAT
|
|
|
99
101
|
* back onto the pod), the key from the pod, a deliverer that sends only when
|
|
100
102
|
* asked, a publisher, and the intake. Returns the agent, or { skipped }.
|
|
101
103
|
*/
|
|
102
|
-
export async function actingAgent(at, store, lease, { log = console.log, push = false } = {}) {
|
|
104
|
+
export async function actingAgent(at, store, lease, { log = console.log, push = false, checkLetters = null } = {}) {
|
|
103
105
|
const config = store.getConfig();
|
|
104
106
|
if (!config) return { skipped: 'no account on the pod' };
|
|
105
107
|
const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
@@ -115,6 +117,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
|
|
|
115
117
|
// What the owner's own posts, taken at the outbox door, need of an agent.
|
|
116
118
|
const agent = {
|
|
117
119
|
store, publisher, deliverer, remote: at.remote, urls, config, lease, intake: null, viewer: false,
|
|
120
|
+
// The hashtags followed, changed from an app; FediPod in the browser reads them.
|
|
121
|
+
tagfeed: new TagFeed({ store, intake: null, log }),
|
|
118
122
|
configured: () => true, requestTakeover: async () => true,
|
|
119
123
|
};
|
|
120
124
|
const c2s = new C2S({ agent, log });
|
|
@@ -122,6 +126,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
|
|
|
122
126
|
config, urls, remote: at.remote, store, deliverer, publisher, log, lease, push,
|
|
123
127
|
ownerPost: (a, o) => c2s.dispatch(a, o),
|
|
124
128
|
});
|
|
129
|
+
// The gateway's test letter, taken in here and answered (check-letter.mjs).
|
|
130
|
+
agent.intake.checkLetters = checkLetters;
|
|
125
131
|
publisher.resolveActor = (u) => agent.intake.fetchAP(u);
|
|
126
132
|
return agent;
|
|
127
133
|
}
|