fedipod 1.50.2 → 1.56.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.
Files changed (44) hide show
  1. package/README.md +2 -1
  2. package/gateway.md +25 -0
  3. package/gui.md +11 -3
  4. package/lib/client/masto/timelines.mjs +5 -0
  5. package/lib/connections/acctfeed.mjs +4 -1
  6. package/lib/connections/tagfeed.mjs +4 -1
  7. package/lib/core/intake/index.mjs +35 -2
  8. package/lib/core/intake/verify.mjs +3 -1
  9. package/lib/core/pod-only.mjs +4 -2
  10. package/lib/core/publisher/index.mjs +19 -5
  11. package/lib/gateway/account-agent.mjs +11 -5
  12. package/lib/gateway/check-letter.mjs +123 -0
  13. package/lib/gateway/connected-feeds.mjs +116 -0
  14. package/lib/gateway/copy.mjs +19 -0
  15. package/lib/gateway/forum-pages.mjs +59 -0
  16. package/lib/gateway/front-core.mjs +7 -4
  17. package/lib/gateway/held-mail.mjs +25 -1
  18. package/lib/gateway/keeper.mjs +2 -1
  19. package/lib/gateway/masto-gateway.mjs +47 -7
  20. package/lib/surface/routes/owner.mjs +7 -4
  21. package/package.json +3 -4
  22. package/scripts/build-app.mjs +3 -0
  23. package/scripts/check-netlify-bundles.mjs +3 -0
  24. package/scripts/forum-package.mjs +26 -0
  25. package/scripts/stage-site.mjs +34 -13
  26. package/vendor/sanitize-html.cjs +358 -171
  27. package/web/admin/bar.js +45 -0
  28. package/web/admin/group.js +17 -0
  29. package/web/admin/index.html +12 -8
  30. package/web/admin/record.js +19 -41
  31. package/web/admin/upkeep.js +3 -0
  32. package/web/app/README.md +3 -3
  33. package/web/app/admin-facade.mjs +19 -16
  34. package/web/app/agent.mjs +65 -2
  35. package/web/app/atproto-browser.mjs +28 -22
  36. package/web/app/deliver-relay.mjs +2 -1
  37. package/web/app/dist/boot.js +1 -0
  38. package/web/app/dist/boot.js.map +2 -2
  39. package/web/app/dist/sw.js +567 -265
  40. package/web/app/dist/sw.js.map +4 -4
  41. package/web/app/fediacct-browser.mjs +32 -30
  42. package/web/app/idb-kv.mjs +4 -4
  43. package/web/app/shims/prelude.js +1 -0
  44. 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 stays in your browser unless you choose to keep it on your pod.
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 and the local log, which a browser does not have.
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; the manage page's
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
@@ -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 left = Number(res.headers.get('x-ratelimit-remaining'));
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
 
@@ -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
- const t = setTimeout(() => this.drain().catch(e => this.log(`drain: ${e.message}`)), CHAIN_GAP_MS);
206
- t.unref?.();
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.
@@ -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
- export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-');
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: wire.followsNeedApproval(this.config),
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
- if (!unreachable.length) {
287
- this.store.write('published.json',
288
- { ...this.store.read('published.json', {}), surfaceDigest: surface });
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);
@@ -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: ['keys.json'] }), log }),
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
  }
@@ -0,0 +1,123 @@
1
+ // check-letter.mjs — the gateway's test letter. Every six hours it writes to
2
+ // its admin's own account through the account's door, as any server would,
3
+ // and FediPod answers once it has taken the letter in (POST /api/check), or
4
+ // the gateway's own reader does when it reads held mail (account-agent.mjs).
5
+ // A letter still unanswered after FediPod has been open an hour means mail is
6
+ // not getting through, and the owner's phones are told. The letter itself is
7
+ // shown nowhere (lib/core/intake: checkLetters).
8
+ import crypto from 'node:crypto';
9
+ import { PRESENT_MS } from './held-mail.mjs';
10
+
11
+ export const LETTER_EVERY_MS = 6 * 3600_000;
12
+ export const ANSWER_WITHIN_MS = 3600_000;
13
+ const LETTER_ID = /^https?:\/\/[^/]+\/check\/letters\/[0-9a-f]{24}$/u;
14
+
15
+ const base = (origin) => String(origin || '').replace(/\/$/u, '');
16
+ export const checkActor = (origin) => `${base(origin)}/check/actor`;
17
+ const stateKey = (handle) => `check/${handle}`;
18
+ const read = async (kv, handle) => { try { return JSON.parse((await kv.get(stateKey(handle)))?.text || 'null') || {}; } catch { return {}; } };
19
+ const save = (kv, handle, st) => kv.set(stateKey(handle), JSON.stringify(st));
20
+
21
+ // Who gets letters: the admin's own browser accounts, still at this gateway.
22
+ const checked = (ctx, rec) => !!(ctx.adminWebId && rec?.openedAt && rec.actorUrl && rec.webId === ctx.adminWebId && !rec.movedTo);
23
+
24
+ /** The letter, as the door takes it: a direct note to the account. */
25
+ export function letterFor(origin, letter, to) {
26
+ const actor = checkActor(origin);
27
+ return {
28
+ '@context': 'https://www.w3.org/ns/activitystreams',
29
+ id: letter, type: 'Create', actor, to: [to],
30
+ object: { id: `${letter}#note`, type: 'Note', attributedTo: actor, to: [to], content: '<p>A test letter from this gateway.</p>' },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * The round's part: for each account checked, a letter unanswered after an
36
+ * hour of FediPod open is alerted once, and a new one goes when none is
37
+ * waiting and six hours have passed since the last. `send(handle, rec, doc)`
38
+ * delivers a letter; `alert(handle, text)` tells the owner's phones.
39
+ */
40
+ export async function roundChecks(ctx, { kv = ctx.mastoKv, now = Date.now(), send, alert, log = console.log } = {}) {
41
+ if (!kv || !ctx.adminWebId || !ctx.listDirectory || !ctx.frontOrigin) return;
42
+ const rows = await ctx.listDirectory();
43
+ for (const [handle, rec] of Object.entries(rows || {})) {
44
+ if (!checked(ctx, rec)) continue;
45
+ const st = await read(kv, handle);
46
+ const presentAt = Number(await ctx.presentAt?.(handle).catch(() => 0)) || 0;
47
+ const open = !!presentAt && now - presentAt < PRESENT_MS;
48
+ const waiting = st.letter && !st.answeredAt && !st.alertedAt;
49
+ if (waiting) {
50
+ // Open since this round, or since an earlier one; closed starts it again.
51
+ st.openSince = open ? (st.openSince ?? now) : null;
52
+ if (open && now - st.openSince >= ANSWER_WITHIN_MS) {
53
+ st.alertedAt = now;
54
+ await alert(handle, 'Its test letter has not reached your FediPod after an hour with FediPod open. Mail may not be getting through.')
55
+ .catch((e) => log(`check @${handle}: alert not sent: ${e?.message || e}`));
56
+ log(`check @${handle}: letter not answered after an hour open — owner told`);
57
+ }
58
+ await save(kv, handle, st);
59
+ continue;
60
+ }
61
+ if (typeof st.sentAt === 'number' && now - st.sentAt < LETTER_EVERY_MS) continue;
62
+ const letter = `${base(ctx.frontOrigin)}/check/letters/${crypto.randomBytes(12).toString('hex')}`;
63
+ const status = await send(handle, rec, letterFor(ctx.frontOrigin, letter, rec.actorUrl)).catch((e) => { log(`check @${handle}: letter not sent: ${e?.message || e}`); return 0; });
64
+ if (status !== 202) { log(`check @${handle}: the door answered ${status} to the letter`); continue; }
65
+ await save(kv, handle, { letter, sentAt: now });
66
+ log(`check @${handle}: letter sent`);
67
+ }
68
+ }
69
+
70
+ /** The letter taken in: true when it is the one waiting. */
71
+ export async function answerLetter(kv, handle, letter, { now = Date.now() } = {}) {
72
+ if (!kv || !LETTER_ID.test(String(letter || ''))) return false;
73
+ const st = await read(kv, handle);
74
+ if (st.letter !== letter) return false;
75
+ if (!st.answeredAt) await save(kv, handle, { ...st, answeredAt: now });
76
+ return true;
77
+ }
78
+
79
+ /** When the letter waiting was alerted and is still unanswered, its time; else null. */
80
+ export async function missingLetter(kv, handle) {
81
+ if (!kv) return null;
82
+ const st = await read(kv, handle);
83
+ return st.alertedAt && !st.answeredAt ? new Date(st.alertedAt).toISOString() : null;
84
+ }
85
+
86
+ /** What the gateway's reader needs to take a letter in at the gateway itself. */
87
+ export const checkLettersFor = (ctx, handle) => (ctx?.frontOrigin && ctx.mastoKv
88
+ ? { actor: checkActor(ctx.frontOrigin), taken: async (letter) => { await answerLetter(ctx.mastoKv, handle, letter); } }
89
+ : null);
90
+
91
+ // The letter's sender, for anything that looks it up: an application, whose
92
+ // inbox takes and keeps nothing. Returns a response, or null for other paths.
93
+ export function routeCheckActor(request, pathname, ctx, { j }) {
94
+ if (pathname === '/check/actor' && (request.method === 'GET' || request.method === 'HEAD')) {
95
+ const id = checkActor(ctx.frontOrigin || new URL(request.url).origin);
96
+ return j(200, {
97
+ '@context': 'https://www.w3.org/ns/activitystreams',
98
+ id, type: 'Application', preferredUsername: 'check', name: `${ctx.host || new URL(id).host} test letters`,
99
+ inbox: `${base(new URL(id).origin)}/check/inbox`,
100
+ }, 'application/activity+json');
101
+ }
102
+ if (pathname === '/check/inbox' && request.method === 'POST') return { status: 202, headers: {}, body: null };
103
+ return null;
104
+ }
105
+
106
+ // The owner's FediPod, saying it took the letter in, proved like /api/here;
107
+ // and the letter's sender, for anything that looks it up.
108
+ export async function routeCheckApi(request, pathname, ctx, deps) {
109
+ if (pathname.startsWith('/check/')) return routeCheckActor(request, pathname, ctx, deps);
110
+ if (pathname !== '/api/check') return null;
111
+ const { j } = deps;
112
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
113
+ if (request.method !== 'POST') return null;
114
+ let body;
115
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
116
+ const handle = String(body.handle || '').toLowerCase();
117
+ const rec = await ctx.lookup(handle);
118
+ if (!rec) return j(404, { error: 'no such account' });
119
+ const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
120
+ if (who.error) return who.error;
121
+ const answered = await answerLetter(ctx.mastoKv, handle, String(body.letter || ''));
122
+ return j(200, { ok: true, handle, answered });
123
+ }
@@ -0,0 +1,116 @@
1
+ // connected-feeds.mjs — the owner's other accounts read into theirs at the
2
+ // gateway, for an app such as Elk while FediPod is closed in the browser.
3
+ //
4
+ // FediPod in the browser reads the owner's connected Mastodon-API accounts and
5
+ // Bluesky account every five minutes while it is open (acctfeed.mjs,
6
+ // bskyfeed.mjs). Here the same reads are made, at most every five minutes,
7
+ // when an app asks for the home timeline or the notifications and nobody else
8
+ // holds the account. The logins are read from the pod for the one read and are
9
+ // never kept at the gateway (lib/core/pod-only.mjs); a login the far server
10
+ // renews goes back to the pod.
11
+ import { FediAccounts } from '../connections/fediacct.mjs';
12
+ import { AcctFeed } from '../connections/acctfeed.mjs';
13
+ import { Atproto } from '../connections/atproto.mjs';
14
+ import { BskyFeed } from '../connections/bskyfeed.mjs';
15
+ import { safeFetch } from '../shared/safefetch.mjs';
16
+ import { apUrls } from '../core/wire.mjs';
17
+ import { lockCopy, heldByBrowser } from './copy.mjs';
18
+ import { reachAccount, settle, stateAndLease } from './account-agent.mjs';
19
+
20
+ export const FEEDS_EVERY_MS = 5 * 60_000;
21
+ // An account with nothing connected is looked at again only this often.
22
+ export const NONE_EVERY_MS = 60 * 60_000;
23
+ // Each request to another server is cut short, and Bluesky is not started
24
+ // once this much of the read has gone, so an app's write waiting on the
25
+ // account does not wait long.
26
+ const ASK_MS = 6_000;
27
+ const READ_MS = 15_000;
28
+ const CONNECTED = ['fediaccts.json', 'atproto.json'];
29
+
30
+ // The logins as FediPod in the browser keeps them: one document for the
31
+ // Mastodon-API accounts, by id, and one for Bluesky.
32
+ function accountsVault(store) {
33
+ const all = () => store.read('fediaccts.json', {}) || {};
34
+ return {
35
+ names: () => Object.keys(all()),
36
+ read: (id) => all()[id] || null,
37
+ write: (id, rec) => store.write('fediaccts.json', { ...all(), [id]: { ...rec, storage: 'pod' } }),
38
+ remove: async () => false,
39
+ };
40
+ }
41
+ const NO_APPS = { names: () => [], read: () => null, write: () => {}, remove: async () => false };
42
+ function blueskyVault(store) {
43
+ return {
44
+ names: () => (store.read('atproto.json', null) ? ['bluesky'] : []),
45
+ read: () => store.read('atproto.json', null),
46
+ write: (_, rec) => store.write('atproto.json', { ...rec, storage: 'pod' }),
47
+ remove: async () => false,
48
+ };
49
+ }
50
+
51
+ const dueKey = (handle) => `${handle}/feeds-due`;
52
+
53
+ /** For an app checking in: when due, and never over FediPod open in a browser. */
54
+ export async function readConnectedForApp(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
55
+ if (!ctx.copyKv) return 0;
56
+ const due = async () => Date.now() >= Number((await ctx.copyKv.get(dueKey(handle)))?.text || 0);
57
+ // FediPod open in a browser reads them itself; the pod is not asked.
58
+ if (!await due() || await heldByBrowser(ctx.copyKv, handle)) return 0;
59
+ const unlock = await lockCopy(ctx.copyKv, handle, { waitMs: 1000 });
60
+ if (!unlock) return 0;
61
+ try {
62
+ // Another request may have read them while this one waited.
63
+ if (!await due()) return 0;
64
+ await ctx.copyKv.set(dueKey(handle), String(Date.now() + FEEDS_EVERY_MS));
65
+ const out = await readConnected(ctx, handle, rec, { log, fetcher, session });
66
+ if (out.none) await ctx.copyKv.set(dueKey(handle), String(Date.now() + NONE_EVERY_MS));
67
+ return out.added || 0;
68
+ } finally { await unlock(); }
69
+ }
70
+
71
+ /**
72
+ * One read of every connected account into the account, by a caller holding
73
+ * lockCopy. Returns { added } or { none } when nothing is connected.
74
+ */
75
+ export async function readConnected(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
76
+ const at = await reachAccount(ctx, handle, rec, { log, session });
77
+ if (at.skipped) return { added: 0 };
78
+ if (!(await settle(ctx, handle, rec, at, { log })).ok) return { added: 0 };
79
+ // The key is not needed to read: only the logins come from the pod.
80
+ const { store, lease } = stateAndLease(ctx, handle, at, { log, podNames: [] });
81
+ try { await store.refresh(CONNECTED); } catch (e) { log(`@${handle}: connected accounts: ${e.message}`); return { added: 0 }; }
82
+ const out = fetcher || ctx.fetchImpl || null;
83
+ const ask = (url, init = {}) => {
84
+ const cut = { ...init, signal: AbortSignal.timeout(ASK_MS) };
85
+ return out ? out(url, cut) : safeFetch(url, cut);
86
+ };
87
+ const said = (m) => log(`@${handle}: ${m}`);
88
+ const masto = new FediAccounts({ vault: accountsVault(store), apps: NO_APPS, log: said, fetcher: ask });
89
+ const bsky = new Atproto({ vault: blueskyVault(store), log: said, fetcher: ask });
90
+ const bskyOn = () => bsky.connected() && !bsky.read()?.feedPaused;
91
+ if (!masto.connected() && !bskyOn()) return { none: true };
92
+ if (!await lease.acquire()) return { added: 0 };
93
+ try {
94
+ await store.load();
95
+ const config = store.getConfig();
96
+ if (!config || config.quiescedAt) return { added: 0 };
97
+ // Stamped as FediPod in the browser stamps them, so a login made for
98
+ // another actor is left alone here too.
99
+ const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
100
+ masto.actorId = bsky.actorId = apUrls(at.pod, config.root || at.root, { publicBase }).actor;
101
+ const started = Date.now();
102
+ let added = 0;
103
+ if (masto.connected()) {
104
+ const feed = new AcctFeed({ store, accounts: masto, log: said });
105
+ await feed.sweep();
106
+ added += feed.lastAdded;
107
+ }
108
+ if (bskyOn() && Date.now() - started < READ_MS) {
109
+ const feed = new BskyFeed({ store, atproto: bsky, log: said });
110
+ await feed.sweep();
111
+ added += feed.lastAdded;
112
+ }
113
+ if (!await store.commit()) said('connected accounts read, but not written to the copy');
114
+ return { added };
115
+ } finally { await lease.release().catch(() => {}); }
116
+ }
@@ -375,6 +375,25 @@ export async function flushCopy(kv, h, { pod, log = () => {} }) {
375
375
  let changed = false;
376
376
  for (const b of docs) {
377
377
  const name = b.key.slice(`${h}/d/`.length);
378
+ // A pod-only document in the copy was put there before it was pod-only.
379
+ // It goes to the pod unless the pod's is newer (changed since the copy
380
+ // last wrote it, or made since the copy was), then out of the copy and
381
+ // its record.
382
+ if (podOnly(name)) {
383
+ const got = await kv.get(b.key);
384
+ const there = typeof pod.read === 'function' ? await pod.read(name).catch(() => null) : null;
385
+ const podMoved = !!there?.ok && (!flushed[name] || hashOf(there.body) !== flushed[name].hash);
386
+ if (got && !podMoved && flushed[name]?.hash !== hashOf(got.text)) {
387
+ const w = await pod.write(name, got.text, 'application/json');
388
+ if (!w.ok) { log(`copy @${h}: ${name} not written to the pod (${w.why})`); continue; }
389
+ n++;
390
+ }
391
+ await kv.delete(b.key);
392
+ delete flushed[name];
393
+ changed = true;
394
+ log(`copy @${h}: ${name} is kept on the pod only now, and left the copy`);
395
+ continue;
396
+ }
378
397
  if (flushed[name]?.etag === b.etag) continue;
379
398
  const got = await kv.get(b.key);
380
399
  if (!got) continue;