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/web/admin/bar.js
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
// relationship.
|
|
5
5
|
|
|
6
6
|
(() => {
|
|
7
|
+
// A page loaded past its worker (a hard reload does that) leaves FediPod's
|
|
8
|
+
// own part asleep in the browser: nothing the page asks reaches it. Loaded
|
|
9
|
+
// again, normally, the worker answers. Once per tab, in case it cannot.
|
|
10
|
+
try {
|
|
11
|
+
const sw = navigator.serviceWorker;
|
|
12
|
+
const reloaded = performance.getEntriesByType?.('navigation')?.[0]?.type === 'reload';
|
|
13
|
+
if (sw?.controller) sessionStorage.removeItem('fedipod-reattached');
|
|
14
|
+
else if (sw && reloaded && !sessionStorage.getItem('fedipod-reattached')) {
|
|
15
|
+
sw.getRegistration().then((reg) => {
|
|
16
|
+
if (!reg?.active) return;
|
|
17
|
+
sessionStorage.setItem('fedipod-reattached', '1');
|
|
18
|
+
location.reload();
|
|
19
|
+
}).catch(() => {});
|
|
20
|
+
}
|
|
21
|
+
} catch { /* no storage to guard a second try: leave the page as it is */ }
|
|
22
|
+
|
|
7
23
|
// The three destinations are <a href> now: they are navigations, so they
|
|
8
24
|
// belong in a screen reader's links list, and middle-click and open-in-new-tab
|
|
9
25
|
// work the way they do everywhere else. Nothing here has to drive them.
|
|
@@ -28,6 +44,7 @@
|
|
|
28
44
|
// leaves the account looking broken from outside. Say so where the
|
|
29
45
|
// account is, and let the owner take it back from here.
|
|
30
46
|
if (s?.mode === 'viewer') viewerBanner();
|
|
47
|
+
if (s?.trouble?.count > 0) troubleBanner(s.trouble.count);
|
|
31
48
|
}).catch(() => { /* not up yet; the bar still works */ });
|
|
32
49
|
|
|
33
50
|
// The actors dropdown, on a page whose bar carries one. The record page
|
|
@@ -64,6 +81,34 @@
|
|
|
64
81
|
}
|
|
65
82
|
})();
|
|
66
83
|
|
|
84
|
+
// Mail FediPod could not read, until its owner opens the dead letters
|
|
85
|
+
// (upkeep.js), on every page with the bar.
|
|
86
|
+
function troubleBanner(count) {
|
|
87
|
+
if (document.getElementById('trouble-banner')) return;
|
|
88
|
+
const base = location.pathname.replace(/\/admin\/.*$/u, '');
|
|
89
|
+
const onManage = !!document.getElementById('do-deadletter');
|
|
90
|
+
const box = document.createElement('div');
|
|
91
|
+
box.id = 'trouble-banner';
|
|
92
|
+
box.setAttribute('role', 'alert');
|
|
93
|
+
box.style.cssText = 'padding:.7rem 1rem;background:var(--field-bg,#ececec);color:var(--fg,#1a1a1a);'
|
|
94
|
+
+ 'border-bottom:3px solid var(--err,#b00020);border-left:6px solid var(--err,#b00020);font:inherit';
|
|
95
|
+
const head = document.createElement('strong');
|
|
96
|
+
head.style.color = 'var(--err,#b00020)';
|
|
97
|
+
head.textContent = 'Mail not read. ';
|
|
98
|
+
const said = document.createElement('span');
|
|
99
|
+
said.textContent = `FediPod could not read ${count === 1 ? 'a piece' : `${count} pieces`} of your mail. `;
|
|
100
|
+
box.append(head, said);
|
|
101
|
+
if (onManage) {
|
|
102
|
+
box.append('Show dead letters lists what it gave up on.');
|
|
103
|
+
} else {
|
|
104
|
+
const go = document.createElement('a');
|
|
105
|
+
go.href = base + '/admin/';
|
|
106
|
+
go.textContent = 'See them on your manage page';
|
|
107
|
+
box.append(go);
|
|
108
|
+
}
|
|
109
|
+
document.body.prepend(box);
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
// "Another device is active": what it means for this account, and the way out.
|
|
68
113
|
// The lease is a document on the pod with a five-minute life; a browser that
|
|
69
114
|
// was closed leaves one behind that nothing renews, and every reload asks
|
package/web/admin/group.js
CHANGED
|
@@ -110,6 +110,23 @@ const setAutoAccept = async (auto) => {
|
|
|
110
110
|
onPick(FOLLOWS_PICK, (v) => setAutoAccept(v === 'auto'),
|
|
111
111
|
() => { FOLLOWS_PICK.value = config?.autoAcceptFollows ? 'auto' : 'approve'; });
|
|
112
112
|
|
|
113
|
+
// Hashtag posts in the home timeline, or only on the public timeline and each
|
|
114
|
+
// hashtag's page. FediPod goes on bringing them in either way.
|
|
115
|
+
let homeTagsBusy = false;
|
|
116
|
+
const setTagsInHome = async (show) => {
|
|
117
|
+
if (homeTagsBusy || show === (config.tagsInHome !== false)) return;
|
|
118
|
+
homeTagsBusy = true;
|
|
119
|
+
try {
|
|
120
|
+
if (await write('/config', { tagsInHome: show },
|
|
121
|
+
show ? 'hashtag posts are shown in your home timeline' : 'hashtag posts are kept out of your home timeline')) {
|
|
122
|
+
config.tagsInHome = show;
|
|
123
|
+
}
|
|
124
|
+
HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
|
|
125
|
+
} finally { homeTagsBusy = false; }
|
|
126
|
+
};
|
|
127
|
+
onPick(HOME_TAGS_PICK, (v) => setTagsInHome(v === 'show'),
|
|
128
|
+
() => { HOME_TAGS_PICK.value = config?.tagsInHome !== false ? 'show' : 'hide'; });
|
|
129
|
+
|
|
113
130
|
// One row: what it is, then what can be done to it.
|
|
114
131
|
function row(text, sub, actions) {
|
|
115
132
|
const li = document.createElement('li');
|
package/web/admin/index.html
CHANGED
|
@@ -129,11 +129,10 @@ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
|
129
129
|
.muted { color: var(--muted); }
|
|
130
130
|
/* The actors dropdown rides the bar, at the bar's own text scale. */
|
|
131
131
|
#bar select { font-size: .95rem; }
|
|
132
|
-
/* The per-account Options disclosure
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
connectionControls). */
|
|
132
|
+
/* The per-account Options disclosure, for the Node agent's Bluesky — a
|
|
133
|
+
summary opening Show/Hide in Feed, Cross-post On/Off, Connected/Disconnect.
|
|
134
|
+
A browser-build account's dropdown sits in the row directly instead
|
|
135
|
+
(record.js connectionControls). */
|
|
137
136
|
details.opts { display: inline-block; }
|
|
138
137
|
details.opts > summary { cursor: pointer; color: var(--link); list-style: none; }
|
|
139
138
|
details.opts > summary::-webkit-details-marker { display: none; }
|
|
@@ -143,9 +142,8 @@ details.opts > div { margin: .4rem 0 .2rem; padding: .5rem .6rem; display: flex;
|
|
|
143
142
|
gap: .4rem; align-items: stretch; border: 1px solid var(--line); border-radius: .3rem;
|
|
144
143
|
background: var(--raised); min-width: 11rem; }
|
|
145
144
|
details.opts > div select { width: 100%; }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
connectionControls) — extra room from the account name before them. */
|
|
145
|
+
/* The connection dropdown that sits in the row itself (record.js
|
|
146
|
+
connectionControls) — extra room from the account name before it. */
|
|
149
147
|
.conn-controls { display: inline-flex; align-items: center; gap: .4rem; margin-left: 1rem; }
|
|
150
148
|
/* The alias row inside the Transfer-an-account-here panel. */
|
|
151
149
|
#alias-ctl { display: flex; gap: .5rem; align-items: center; margin-top: .3rem; }
|
|
@@ -327,6 +325,12 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
327
325
|
<option value="auto">accepted automatically</option>
|
|
328
326
|
</select>
|
|
329
327
|
</span>
|
|
328
|
+
<span id="home-tags-ctl" hidden>
|
|
329
|
+
<select id="home-tags-pick" aria-label="Hashtag posts in your home timeline" title=" Whether posts brought in for the hashtags you follow appear in your home timeline; hidden, they stay on the public timeline and on each hashtag's page">
|
|
330
|
+
<option value="show">shown</option>
|
|
331
|
+
<option value="hide">hidden</option>
|
|
332
|
+
</select>
|
|
333
|
+
</span>
|
|
330
334
|
<span id="ident-ctl" hidden>
|
|
331
335
|
<button type="button" id="ident-open" class="inline" title=" Connect an account you hold on another network, so its timeline joins the one you read here">Connect an identity</button>
|
|
332
336
|
</span>
|
package/web/admin/record.js
CHANGED
|
@@ -17,6 +17,8 @@ const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid
|
|
|
17
17
|
const IDENT_CTL = $('ident-ctl');
|
|
18
18
|
const FOLLOWS_CTL = $('follows-ctl');
|
|
19
19
|
const FOLLOWS_PICK = $('follows-pick');
|
|
20
|
+
const HOME_TAGS_CTL = $('home-tags-ctl');
|
|
21
|
+
const HOME_TAGS_PICK = $('home-tags-pick');
|
|
20
22
|
const UPDATE_CTL = $('update-ctl');
|
|
21
23
|
const UPDATE_WORD = $('update-word');
|
|
22
24
|
const UPDATE_GO = $('update-go');
|
|
@@ -130,7 +132,6 @@ function identityRows() {
|
|
|
130
132
|
crossPost: 'crossPost' in config.atproto ? config.atproto.crossPost : null,
|
|
131
133
|
feed: (paused) => ['/atproto', { feedPaused: paused }],
|
|
132
134
|
cross: (on) => ['/atproto', { crossPost: on }],
|
|
133
|
-
store: (w) => ['/atproto', { storage: w }],
|
|
134
135
|
off: () => ['/atproto/disconnect', {}],
|
|
135
136
|
});
|
|
136
137
|
for (const acct of config.fediAccounts || []) accounts.push({
|
|
@@ -141,7 +142,6 @@ function identityRows() {
|
|
|
141
142
|
feedPaused: !acct.enabled,
|
|
142
143
|
crossPost: null,
|
|
143
144
|
feed: (paused) => ['/fediacct', { id: acct.id, enabled: !paused }],
|
|
144
|
-
store: (w) => ['/fediacct', { id: acct.id, storage: w }],
|
|
145
145
|
off: () => ['/fediacct/disconnect', { id: acct.id }],
|
|
146
146
|
});
|
|
147
147
|
for (const acct of accounts) {
|
|
@@ -149,18 +149,16 @@ function identityRows() {
|
|
|
149
149
|
name.textContent = acct.handle;
|
|
150
150
|
if (acct.href) { name.href = acct.href; name.target = '_blank'; name.rel = 'noopener'; name.title = ' Open this account elsewhere in a new tab'; }
|
|
151
151
|
if (acct.warn) name.className = 'warn';
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// disclosure it always has is unchanged.
|
|
152
|
+
// A browser-build account (it says where it is stored) gets its dropdown
|
|
153
|
+
// in the row itself. The Node agent's Bluesky, which shows a cross-post
|
|
154
|
+
// toggle instead, keeps the Options disclosure it always has.
|
|
156
155
|
row(acct.label, name, ' ', ...(acct.storage ? connectionControls(acct) : [optionsMenu(acct)]));
|
|
157
156
|
}
|
|
158
157
|
return out;
|
|
159
158
|
}
|
|
160
159
|
|
|
161
|
-
// Connected, Paused or Disconnected,
|
|
162
|
-
//
|
|
163
|
-
// to sit behind an Options disclosure; only reached where acct.storage is set.
|
|
160
|
+
// Connected, Paused or Disconnected, as one dropdown in the row; only reached
|
|
161
|
+
// where acct.storage is set. The key is kept on the pod, with no choice to make.
|
|
164
162
|
function connectionControls(acct) {
|
|
165
163
|
const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
|
|
166
164
|
const pick = (opts, current, onChange, title) => {
|
|
@@ -177,23 +175,14 @@ function connectionControls(acct) {
|
|
|
177
175
|
? send(acct.off(), 'disconnected')
|
|
178
176
|
: send(acct.feed(v === 'paused'), v === 'paused' ? 'paused adding to your feed' : 'added to your home feed')),
|
|
179
177
|
' Connected feeds this account into your home feed here; Paused keeps the account but stops adding its posts; Disconnected removes it.');
|
|
180
|
-
const storage = pick(
|
|
181
|
-
[['pod', 'Store key on Pod'], ['browser', 'Store key in Browser']],
|
|
182
|
-
acct.storage,
|
|
183
|
-
(v) => {
|
|
184
|
-
if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
|
|
185
|
-
send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
|
|
186
|
-
},
|
|
187
|
-
' On your pod the key follows you to any browser you sign in from, and a full-access token then lives there; in this browser the token never leaves this device.');
|
|
188
178
|
const wrap = document.createElement('span');
|
|
189
179
|
wrap.className = 'conn-controls';
|
|
190
|
-
wrap.append(conn
|
|
180
|
+
wrap.append(conn);
|
|
191
181
|
return [wrap];
|
|
192
182
|
}
|
|
193
183
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// disconnect.
|
|
184
|
+
// The Node agent's Bluesky carries an Options menu instead: pause its feed,
|
|
185
|
+
// its cross-post toggle, disconnect.
|
|
197
186
|
function optionsMenu(acct) {
|
|
198
187
|
const d = document.createElement('details');
|
|
199
188
|
d.className = 'opts';
|
|
@@ -218,14 +207,8 @@ function optionsMenu(acct) {
|
|
|
218
207
|
pick([['show', 'Add to home feed'], ['hide', 'Pause adding to home feed']], acct.feedPaused ? 'hide' : 'show',
|
|
219
208
|
(v) => send(acct.feed(v === 'hide'), v === 'hide' ? 'paused adding to your feed' : 'added to your home feed'));
|
|
220
209
|
}
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
if (acct.storage) {
|
|
224
|
-
pick([['browser', 'Store key in Browser'], ['pod', 'Store key on Pod']], acct.storage, (v) => {
|
|
225
|
-
if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
|
|
226
|
-
send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
|
|
227
|
-
});
|
|
228
|
-
} else if (acct.crossPost !== null) {
|
|
210
|
+
// The Node agent's Bluesky: its cross-post toggle.
|
|
211
|
+
if (acct.crossPost !== null) {
|
|
229
212
|
pick([['on', 'Cross-post On'], ['off', 'Cross-post Off']], acct.crossPost ? 'on' : 'off',
|
|
230
213
|
(v) => send(acct.cross(v === 'on'), ''));
|
|
231
214
|
}
|
|
@@ -233,14 +216,6 @@ function optionsMenu(acct) {
|
|
|
233
216
|
pick([['connected', 'Connected'], ['disconnect', 'Disconnect']], 'connected',
|
|
234
217
|
(v) => { if (v === 'disconnect') send(acct.off(), 'disconnected'); else load(); });
|
|
235
218
|
|
|
236
|
-
// The storage consequence, at the bottom of the box under every dropdown.
|
|
237
|
-
if (acct.storage) {
|
|
238
|
-
const hint = document.createElement('p'); hint.className = 'opt-hint';
|
|
239
|
-
hint.textContent = acct.storage === 'pod'
|
|
240
|
-
? 'Follows you to every browser you sign in from; a full-access token lives on your pod.'
|
|
241
|
-
: 'Stays in this browser; the token never leaves this device.';
|
|
242
|
-
body.appendChild(hint);
|
|
243
|
-
}
|
|
244
219
|
return d;
|
|
245
220
|
}
|
|
246
221
|
|
|
@@ -265,9 +240,6 @@ function render() {
|
|
|
265
240
|
['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
|
|
266
241
|
config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
|
|
267
242
|
['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
|
|
268
|
-
// Where on the pod the account's data lives: the container its owner chose.
|
|
269
|
-
['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
|
|
270
|
-
config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
|
|
271
243
|
['Other identities', 'ctl'],
|
|
272
244
|
['local store', config.home],
|
|
273
245
|
// The address you actually open, not the bare number — the named origin when
|
|
@@ -282,7 +254,7 @@ function render() {
|
|
|
282
254
|
if (config.movedTo) rows.push(['moved to', config.movedTo]);
|
|
283
255
|
// A person gates followers here; a group's gate is the joins control on its
|
|
284
256
|
// kind row, so the row would be a second switch for the same thing.
|
|
285
|
-
if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl']);
|
|
257
|
+
if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl']);
|
|
286
258
|
// Rows that belong under the one being written, appended after it.
|
|
287
259
|
let kids = null;
|
|
288
260
|
for (const [k, v, link] of rows) {
|
|
@@ -323,6 +295,12 @@ function render() {
|
|
|
323
295
|
FOLLOWS_CTL.hidden = false;
|
|
324
296
|
FOLLOWS_PICK.value = config.autoAcceptFollows !== false ? 'auto' : 'approve';
|
|
325
297
|
}
|
|
298
|
+
if (k === 'hashtag posts in home') {
|
|
299
|
+
dd.textContent = '';
|
|
300
|
+
dd.append(HOME_TAGS_CTL);
|
|
301
|
+
HOME_TAGS_CTL.hidden = false;
|
|
302
|
+
HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
|
|
303
|
+
}
|
|
326
304
|
if (k === 'gateway') {
|
|
327
305
|
dd.textContent = '';
|
|
328
306
|
dd.append(GATEWAY_CTL);
|
package/web/admin/upkeep.js
CHANGED
|
@@ -64,6 +64,9 @@ $('do-deadletter').addEventListener('click', async () => {
|
|
|
64
64
|
if (showingFrom('deadletter')) { closePanels(); return; }
|
|
65
65
|
const { json } = await api('/deadletter');
|
|
66
66
|
output(json?.items?.length ? json.items : 'no dead letters', 'deadletter');
|
|
67
|
+
// Looked at: the bar stops saying mail was not read.
|
|
68
|
+
const seen = await postJson('/trouble', { seen: true }).catch(() => null);
|
|
69
|
+
if (seen?.status === 200) document.getElementById('trouble-banner')?.remove();
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
// ---- lifecycle ----
|
package/web/app/README.md
CHANGED
|
@@ -41,9 +41,9 @@ worker answers the data endpoints it calls. See
|
|
|
41
41
|
| file | what |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `admin-facade.mjs` | Answers the owner/manage endpoints (`/status`, `/config`, `/gateway`, `/alias`, `/rotate-key`, `/rebuild`, `/move`, `/retire`, `/inbox/prune`, `/deadletter`, `/blocks`, `/atproto*`, `/fediacct*`) over the browser agent, mirroring `lib/device/admin.mjs`. Personal only. |
|
|
44
|
-
| `atproto-browser.mjs` | The Bluesky connection (`lib/connections/atproto.mjs`)
|
|
45
|
-
| `fediacct-browser.mjs` | Connections to fediverse accounts on other servers (`lib/connections/fediacct.mjs`): the OAuth dance, the same
|
|
46
|
-
| `idb-kv.mjs` | The per-origin IndexedDB key/value store the two connectors use for
|
|
44
|
+
| `atproto-browser.mjs` | The Bluesky connection (`lib/connections/atproto.mjs`), its credential kept in the owner-only pod state and nowhere else, plus pause. A record found in IndexedDB moves to the pod. |
|
|
45
|
+
| `fediacct-browser.mjs` | Connections to fediverse accounts on other servers (`lib/connections/fediacct.mjs`): the OAuth dance, the credential kept on the pod the same way, and a best-effort server-side revoke on disconnect. |
|
|
46
|
+
| `idb-kv.mjs` | The per-origin IndexedDB key/value store the two connectors use for app registrations, the OAuth pending state, and any credential found there, until it moves to the pod. |
|
|
47
47
|
|
|
48
48
|
`agent.mjs` also holds the single-active-agent **lease** (`lib/core/lease.mjs`) — a
|
|
49
49
|
second device runs read-only until it takes over — and starts the drain/mirrors
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -22,14 +22,14 @@ import { followActor, followHandle } from '../../lib/core/social.mjs';
|
|
|
22
22
|
// The identity itself — changing any means a different actor, i.e. a new setup.
|
|
23
23
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
24
24
|
// Carried in the actor document, so a change is not live until republished.
|
|
25
|
-
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases'];
|
|
25
|
+
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases', 'autoAcceptFollows'];
|
|
26
26
|
|
|
27
27
|
// The data endpoints this agent answers at the origin root (the page computes
|
|
28
28
|
// its base as the origin, so it asks for /status, not /admin/status). Anything
|
|
29
29
|
// not here falls through to the network as a static file or the gateway.
|
|
30
30
|
export const ADMIN_PATHS = new Set([
|
|
31
31
|
'/status', '/config', '/gateway', '/alias', '/rotate-key', '/import',
|
|
32
|
-
'/deadletter', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
|
|
32
|
+
'/deadletter', '/trouble', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
|
|
33
33
|
'/atproto', '/atproto/connect', '/atproto/disconnect',
|
|
34
34
|
'/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
|
|
35
35
|
'/gateway/pause', '/gateway/keep', '/gateway/close',
|
|
@@ -120,6 +120,7 @@ export class AdminFacade {
|
|
|
120
120
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
121
121
|
aliases: cfg.aliases || [],
|
|
122
122
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
123
|
+
tagsInHome: cfg.tagsInHome !== false,
|
|
123
124
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
124
125
|
mode: a.status().mode,
|
|
125
126
|
// Live from the credential's actual backend on THIS browser (a
|
|
@@ -162,7 +163,7 @@ export class AdminFacade {
|
|
|
162
163
|
});
|
|
163
164
|
}
|
|
164
165
|
case '/modqueue': return json(200, []); // person: no moderation queue
|
|
165
|
-
case '/log': return json(200, { lines: [] });
|
|
166
|
+
case '/log': return json(200, { lines: (await a.recentLog?.()) || [] });
|
|
166
167
|
case '/fediacct': return json(200, { accounts: a.fediaccts?.status() || [] });
|
|
167
168
|
case '/import':
|
|
168
169
|
return json(200, a.importer ? a.importer.progress() : { running: false, total: 0, done: 0 });
|
|
@@ -194,6 +195,13 @@ export class AdminFacade {
|
|
|
194
195
|
|
|
195
196
|
// ---- writes ----
|
|
196
197
|
switch (p) {
|
|
198
|
+
// The owner has opened the dead letters: the bar stops saying so.
|
|
199
|
+
case '/trouble': {
|
|
200
|
+
if (body.seen !== true) return json(400, { error: 'seen: true is the only thing to say here' });
|
|
201
|
+
a.troubleSeen?.();
|
|
202
|
+
await a.store.flush();
|
|
203
|
+
return json(200, { ok: true, trouble: a.status().trouble });
|
|
204
|
+
}
|
|
197
205
|
case '/config': {
|
|
198
206
|
const fixed = PERMANENT_CONFIG.filter(k => k in body);
|
|
199
207
|
if (fixed.length) return json(400, { error: `${fixed.join(', ')} cannot be changed — that is the identity itself` });
|
|
@@ -216,6 +224,7 @@ export class AdminFacade {
|
|
|
216
224
|
.map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
|
|
217
225
|
}
|
|
218
226
|
if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
|
|
227
|
+
if ('tagsInHome' in body) cfg.tagsInHome = !!body.tagsInHome;
|
|
219
228
|
const republish = WIRE_CONFIG.some(k => k in body);
|
|
220
229
|
a.store.setConfig(cfg);
|
|
221
230
|
if ('autoAcceptFollows' in body && a.publisher) a.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
|
|
@@ -511,21 +520,18 @@ export class AdminFacade {
|
|
|
511
520
|
await a.store.flush();
|
|
512
521
|
return json(200, { ok: true });
|
|
513
522
|
}
|
|
514
|
-
// Bluesky settings: pause/resume the mirror
|
|
515
|
-
//
|
|
523
|
+
// Bluesky settings: pause/resume the mirror. Cross-post is default-on, not
|
|
524
|
+
// toggled. The credential is kept on the pod, with nowhere else to move.
|
|
516
525
|
case '/atproto': {
|
|
517
526
|
if (!a.atproto?.connected()) return json(400, { error: 'no bluesky account connected' });
|
|
527
|
+
if ('storage' in body) return json(400, { error: 'a connected account is kept on your pod' });
|
|
518
528
|
if ('feedPaused' in body) { a.atproto.setFeedPaused(!!body.feedPaused); a.restartBsky?.(); }
|
|
519
|
-
if ('storage' in body) {
|
|
520
|
-
if (!['pod', 'browser'].includes(body.storage)) return json(400, { error: 'storage must be pod or browser' });
|
|
521
|
-
a.atproto.setStorage(body.storage);
|
|
522
|
-
}
|
|
523
529
|
return json(200, { ok: true, ...a.atproto.status() });
|
|
524
530
|
}
|
|
525
531
|
|
|
526
532
|
// Connect a fediverse account on another server: start its OAuth at that
|
|
527
533
|
// server. The browser is then sent to `authorize`; the code comes back to
|
|
528
|
-
// GET /fediacct/callback (above). The token
|
|
534
|
+
// GET /fediacct/callback (above). The token is kept on the pod.
|
|
529
535
|
case '/fediacct/connect': {
|
|
530
536
|
if (!body.host) return json(400, { error: 'the address of the server is required' });
|
|
531
537
|
try {
|
|
@@ -540,14 +546,11 @@ export class AdminFacade {
|
|
|
540
546
|
a.restartAccts?.();
|
|
541
547
|
return json(200, { ok: true });
|
|
542
548
|
}
|
|
543
|
-
// Pause/resume the mirror
|
|
549
|
+
// Pause/resume the mirror. The credential is kept on the pod.
|
|
544
550
|
case '/fediacct': {
|
|
545
551
|
if (!body.id) return json(400, { error: 'id required' });
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (!['pod', 'browser'].includes(body.storage)) return json(400, { error: 'storage must be pod or browser' });
|
|
549
|
-
row = a.fediaccts.setStorage(body.id, body.storage);
|
|
550
|
-
} else row = a.fediaccts.setEnabled(body.id, !!body.enabled);
|
|
552
|
+
if ('storage' in body) return json(400, { error: 'a connected account is kept on your pod' });
|
|
553
|
+
const row = a.fediaccts.setEnabled(body.id, !!body.enabled);
|
|
551
554
|
if (!row) return json(404, { error: 'no such account' });
|
|
552
555
|
a.restartAccts?.();
|
|
553
556
|
return json(200, { ok: true, account: row });
|
package/web/app/agent.mjs
CHANGED
|
@@ -183,6 +183,34 @@ export class BrowserAgent {
|
|
|
183
183
|
// (lib/gateway/held-mail.mjs: /api/push). Named a moment later, once written,
|
|
184
184
|
// so a burst is one call. One from another network is not pushed.
|
|
185
185
|
static PUSH_AFTER_MS = 2_000;
|
|
186
|
+
|
|
187
|
+
// Mail FediPod could not read: counted until its owner opens the dead
|
|
188
|
+
// letters, shown in the bar meanwhile, and told to their phones at most
|
|
189
|
+
// once an hour (lib/gateway/held-mail.mjs: /api/alert).
|
|
190
|
+
static TROUBLE_ALERT_MS = 60 * 60_000;
|
|
191
|
+
noteTrouble(t, { tell = true } = {}) {
|
|
192
|
+
const had = this.store.read('trouble.json', null) || { count: 0 };
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const due = tell && now - (had.alertedAt ? Date.parse(had.alertedAt) : 0) >= BrowserAgent.TROUBLE_ALERT_MS;
|
|
195
|
+
const next = { ...had, count: (had.count || 0) + 1, firstAt: had.firstAt || t.at, lastAt: t.at,
|
|
196
|
+
last: { kind: t.kind, reason: t.reason }, ...(due ? { alertedAt: new Date(now).toISOString() } : {}) };
|
|
197
|
+
this.store.write('trouble.json', next);
|
|
198
|
+
this.log(`could not read mail (${t.kind}): ${t.reason}`);
|
|
199
|
+
if (due) {
|
|
200
|
+
const many = next.count === 1 ? 'a piece of your mail' : `${next.count} pieces of your mail`;
|
|
201
|
+
this.tellGateway('alert', { handle: this.doorKey, text: `Could not read ${many}. Show dead letters on your manage page lists what it gave up on.` }, { standing: false })
|
|
202
|
+
.catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// The owner has looked: the count starts again, and the hourly limit stands.
|
|
206
|
+
troubleSeen() {
|
|
207
|
+
const had = this.store.read('trouble.json', null);
|
|
208
|
+
if (had?.count) {
|
|
209
|
+
this.store.write('trouble.json', { count: 0, ...(had.alertedAt ? { alertedAt: had.alertedAt } : {}),
|
|
210
|
+
...(had.letterMissing ? { letterMissing: had.letterMissing } : {}) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
notePush(n) {
|
|
187
215
|
if (!this.gatewayApi || n?.bsky || n?.via) return;
|
|
188
216
|
if (!Object.keys(this.store.read('webpush.json', { subs: {} }).subs || {}).length) return;
|
|
@@ -243,6 +271,12 @@ export class BrowserAgent {
|
|
|
243
271
|
async hereAtGateway() {
|
|
244
272
|
const said = await this.tellGateway('here', { handle: this.doorKey, nextAt: this.store ? nextDue(this.store) : null });
|
|
245
273
|
if (said?.status === 200) saveMeta(this.webId, { hereAt: Date.now() }).catch(() => {});
|
|
274
|
+
// The gateway's test letter has not arrived: the bar says so, once per
|
|
275
|
+
// letter. The gateway has told the phones itself.
|
|
276
|
+
if (said?.status === 200 && said.missingLetter && this.store?.read('trouble.json', null)?.letterMissing !== said.missingLetter) {
|
|
277
|
+
this.noteTrouble({ kind: 'test-letter', reason: "the gateway's test letter has not arrived", at: new Date().toISOString() }, { tell: false });
|
|
278
|
+
this.store.write('trouble.json', { ...this.store.read('trouble.json', {}), letterMissing: said.missingLetter });
|
|
279
|
+
}
|
|
246
280
|
// The gateway's admin may have turned the hold off or on (copy-mode.mjs).
|
|
247
281
|
if (said?.status === 200 && typeof said.hold === 'boolean') {
|
|
248
282
|
await followHold(this, this.frontOrigin, said.hold).catch((e) => this.log(`the gateway's hold: ${e.message}`));
|
|
@@ -329,6 +363,14 @@ export class BrowserAgent {
|
|
|
329
363
|
}
|
|
330
364
|
}
|
|
331
365
|
this.store.onEvent = (type, n) => { if (type === 'notification') this.notePush(n); };
|
|
366
|
+
// A profile that says otherwise than the follower setting (written by an
|
|
367
|
+
// older FediPod, or before the setting changed) is written again.
|
|
368
|
+
// Tried at most hourly, so a pod that refuses it is not asked at every wake.
|
|
369
|
+
const tried = this.store.read('published.json', {}).healTriedAt;
|
|
370
|
+
if (!this.publisher.profileAgrees() && !(tried && Date.now() - Date.parse(tried) < 60 * 60_000)) {
|
|
371
|
+
this.store.write('published.json', { ...this.store.read('published.json', {}), healTriedAt: new Date().toISOString() });
|
|
372
|
+
await this.publisher.publishProfile({ force: true }).catch((e) => this.log(`profile: ${e.message}`));
|
|
373
|
+
}
|
|
332
374
|
const now = Date.now();
|
|
333
375
|
// Said before the drain starts, so held mail is in the inbox to be read.
|
|
334
376
|
const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;
|
|
@@ -343,6 +385,9 @@ export class BrowserAgent {
|
|
|
343
385
|
this._schedTimer = setInterval(() => { publishDue(this.store, this.publisher, this.log).catch(() => {}); }, 30_000);
|
|
344
386
|
const drainNow = !warm || now - (warm.drainedAt || 0) >= DRAIN_EVERY_MS || here?.flushed > 0;
|
|
345
387
|
await this.intake.start({ drainNow, subscribe: !warm || !!warm.resubscribe });
|
|
388
|
+
// Connected accounts still kept in this browser go to the pod.
|
|
389
|
+
await this.atproto.moveToPod?.().catch((e) => this.log(`Bluesky account to the pod: ${e.message}`));
|
|
390
|
+
await this.fediaccts.moveToPod?.().catch((e) => this.log(`connected accounts to the pod: ${e.message}`));
|
|
346
391
|
this.startBsky();
|
|
347
392
|
this.startAccts();
|
|
348
393
|
this.tagfeed?.start(this.mirrorStart('tags'));
|
|
@@ -516,7 +561,9 @@ export class BrowserAgent {
|
|
|
516
561
|
let unread = null;
|
|
517
562
|
const fullLoad = () => this.store.load().catch((e) => { unread = e; });
|
|
518
563
|
// Warm: only documents caught mid-write are read again, else a full load.
|
|
519
|
-
|
|
564
|
+
// A kept copy without the container's ETag is one whose last load could
|
|
565
|
+
// not read everything: what it lacks is still on the pod, unread.
|
|
566
|
+
if (this._warm && warmFrom.listingEtag) await this.store.refresh(this._warm.pending).catch(fullLoad);
|
|
520
567
|
else await fullLoad();
|
|
521
568
|
// Config: handed in on sign-up, or read from the pod on a returning sign-in.
|
|
522
569
|
const cfg = config || this.store.getConfig();
|
|
@@ -632,6 +679,17 @@ export class BrowserAgent {
|
|
|
632
679
|
store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
|
|
633
680
|
ownerPost: (a, o) => this.c2s.dispatch(a, o),
|
|
634
681
|
});
|
|
682
|
+
this.intake.onTrouble = (t) => this.noteTrouble(t);
|
|
683
|
+
// The gateway's test letter: answered when taken in, and shown nowhere.
|
|
684
|
+
if (this.gatewayApi) {
|
|
685
|
+
this.intake.checkLetters = {
|
|
686
|
+
actor: `${this.frontOrigin.replace(/\/$/u, '')}/check/actor`,
|
|
687
|
+
taken: async (letter) => {
|
|
688
|
+
const said = await this.tellGateway('check', { handle: this.doorKey, letter }, { standing: false });
|
|
689
|
+
if (said?.status !== 200) throw new Error(`the gateway did not take the answer to its test letter (${said?.status || 'no answer'})`);
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
635
693
|
// The Mastodon facade the service worker serves.
|
|
636
694
|
//
|
|
637
695
|
// It used to be handed `allowed: null`, which reads as "no policy" — and
|
|
@@ -683,8 +741,14 @@ export class BrowserAgent {
|
|
|
683
741
|
// its last full start.
|
|
684
742
|
if (!this._warm) await containers.provisionPrivate(this.remote, this.urls);
|
|
685
743
|
// Connected-account records are reads — safe whether we act or view.
|
|
686
|
-
|
|
687
|
-
|
|
744
|
+
// Connected accounts: their records stay on the pod only, so no listing
|
|
745
|
+
// of the gateway's copy names them; read at every full start, and at the
|
|
746
|
+
// first quick one that has not had them. A Bluesky credential or
|
|
747
|
+
// fediverse accounts still kept in this browser are read as well.
|
|
748
|
+
const readConn = !this._warm || !this._warm.connRead;
|
|
749
|
+
await this.atproto.load({ fromPod: readConn });
|
|
750
|
+
await this.fediaccts.load({ fromPod: readConn });
|
|
751
|
+
if (readConn) saveMeta(this.webId, { connRead: true }).catch(() => {});
|
|
688
752
|
// The lease decides: this device ACTS on the pod, or reads it read-only.
|
|
689
753
|
// A warm restart already holds it, and writes it only when renewal is due.
|
|
690
754
|
this.viewer = !(this._warm && !this._warm.renewDue ? true : await this.lease.acquire());
|
|
@@ -732,6 +796,7 @@ export class BrowserAgent {
|
|
|
732
796
|
update: null,
|
|
733
797
|
inboxCooldownFor: 0,
|
|
734
798
|
stateSkipped: this.store?.lastSkipped || [],
|
|
799
|
+
trouble: this.store?.read('trouble.json', null) || null,
|
|
735
800
|
};
|
|
736
801
|
}
|
|
737
802
|
|
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Same Atproto client the Node agent uses (lib/atproto.mjs). Two browser edges:
|
|
4
4
|
//
|
|
5
|
-
// 1.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// read/write route to that backend; moving between them is just a re-write.
|
|
5
|
+
// 1. The credential lives in the owner-only pod state, on the pod only
|
|
6
|
+
// (lib/core/pod-only.mjs), so it follows the owner to any browser they sign
|
|
7
|
+
// in from. A record an earlier build kept in this browser's IndexedDB is
|
|
8
|
+
// still read, and moveToPod() takes it to the pod.
|
|
10
9
|
// 2. The mirror can be paused (feedPaused) without disconnecting.
|
|
11
10
|
//
|
|
12
11
|
// Bluesky's API answers CORS, so the fetches go direct, not through the relay.
|
|
13
12
|
import { Atproto } from '../../lib/connections/atproto.mjs';
|
|
14
|
-
import { kvGet,
|
|
13
|
+
import { kvGet, kvDel } from './idb-kv.mjs';
|
|
15
14
|
|
|
16
15
|
const IDB_KEY = 'atproto';
|
|
17
16
|
const POD_DOC = 'atproto.json';
|
|
@@ -20,19 +19,24 @@ export class BrowserAtproto extends Atproto {
|
|
|
20
19
|
constructor({ store, actorId = null, log = console.log } = {}) {
|
|
21
20
|
super({ localDir: '/atproto', actorId, log }); // localDir unused; storage below
|
|
22
21
|
this.store = store;
|
|
23
|
-
this._idbRec = null; //
|
|
22
|
+
this._idbRec = null; // a record still in this browser, cached at boot
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
// The IndexedDB copy into memory, so read() stays synchronous as the base
|
|
27
26
|
// class expects. The pod copy is already in the loaded store.
|
|
28
|
-
|
|
27
|
+
// `fromPod`: the pod's record read as well (agent.mjs says when).
|
|
28
|
+
async load({ fromPod = false } = {}) {
|
|
29
|
+
this._idbRec = await kvGet(IDB_KEY).catch(() => null);
|
|
30
|
+
if (fromPod) await this.store.refresh?.([POD_DOC], { ifChanged: true }).catch((e) => this.log(`Bluesky account: ${e.message}`));
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
29
33
|
|
|
30
34
|
// Whichever backend holds it, tagged with where it lives. The stamped-record
|
|
31
35
|
// guard is the base class's.
|
|
32
36
|
read() {
|
|
33
37
|
let rec = null; let where = null;
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const p = this.store.read(POD_DOC, null);
|
|
39
|
+
if (p) { rec = p; where = 'pod'; } else if (this._idbRec) { rec = this._idbRec; where = 'browser'; }
|
|
36
40
|
if (!rec) return null;
|
|
37
41
|
if (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
|
|
38
42
|
this.log(`atproto belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
|
|
@@ -41,18 +45,22 @@ export class BrowserAtproto extends Atproto {
|
|
|
41
45
|
return { ...rec, storage: where };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
// Write to the record's chosen backend (default browser), and clear the other
|
|
45
|
-
// so a moved credential leaves no copy behind.
|
|
46
48
|
write(rec) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
this.store.write(POD_DOC, { ...rec, storage: 'pod' }); this.store.flush?.().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A record still in this browser, onto the pod, and out of the browser once
|
|
53
|
+
// the pod has it. One the pod already holds is newer there.
|
|
54
|
+
async moveToPod() {
|
|
55
|
+
const rec = this._idbRec;
|
|
56
|
+
if (!rec || (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId)) return false;
|
|
57
|
+
if (!this.store.read(POD_DOC, null)) {
|
|
58
|
+
this.store.write(POD_DOC, { ...rec, storage: 'pod' });
|
|
59
|
+
if (!await this.store.commit()) return false;
|
|
55
60
|
}
|
|
61
|
+
this._idbRec = null; await kvDel(IDB_KEY).catch(() => {});
|
|
62
|
+
this.log('the Bluesky account moved from this browser to the pod');
|
|
63
|
+
return true;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
status() {
|
|
@@ -69,8 +77,6 @@ export class BrowserAtproto extends Atproto {
|
|
|
69
77
|
feedActive() { return this.connected() && !this.read()?.feedPaused; }
|
|
70
78
|
setFeedPaused(on) { const rec = this.read(); if (rec) this.write({ ...rec, feedPaused: !!on }); }
|
|
71
79
|
|
|
72
|
-
// Move the credential between backends (the Options menu's Storage control).
|
|
73
|
-
setStorage(where) { const rec = this.read(); if (rec && (where === 'pod' || where === 'browser')) this.write({ ...rec, storage: where }); }
|
|
74
80
|
|
|
75
81
|
async disconnect() {
|
|
76
82
|
const rec = this.read();
|
|
@@ -105,7 +105,8 @@ export class RelayDeliverer extends Deliverer {
|
|
|
105
105
|
const status = r0.status || 0;
|
|
106
106
|
if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
|
|
107
107
|
if (status >= 400) {
|
|
108
|
-
|
|
108
|
+
// `answered`: the far server's own answer, as against the relay's.
|
|
109
|
+
const err = new Error(`${method} ${url} → ${status}`); err.status = status; err.answered = true;
|
|
109
110
|
// The receiving server's own answer to "when should I try again", carried
|
|
110
111
|
// through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
|
|
111
112
|
// what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
|