fedipod 1.30.0 → 1.36.6
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 +1 -1
- package/gateway.md +37 -0
- package/gui.md +2 -2
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +59 -66
- package/lib/gateway/gateway-core.mjs +20 -8
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +189 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/scripts/stage-site.mjs +6 -3
- package/web/admin/bar.css +44 -10
- package/web/admin/client/index.html +29 -2
- package/web/admin/gateway.js +19 -0
- package/web/admin/index.html +46 -3
- package/web/admin/notices-bar.js +90 -0
- package/web/admin/record.js +1 -0
- package/web/admin/setup/index.html +29 -2
- package/web/admin/upkeep.js +5 -1
- package/web/app/admin-facade.mjs +24 -0
- package/web/app/agent.mjs +59 -6
- package/web/app/boot.mjs +2 -0
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +39 -4
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +273 -38
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
|
@@ -28,8 +28,16 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
|
|
|
28
28
|
<h1 class="name">FediPod</h1>
|
|
29
29
|
<select id="actor-pick" aria-label="Local actors"
|
|
30
30
|
title=" Every actor on this machine; choosing one opens its client"></select>
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
<!-- The account group, styled like the client group beside it: one label,
|
|
32
|
+
then the places to go. The browser build adds "sign out" to it. -->
|
|
33
|
+
<span id="account-pick"><span id="account-now">account:</span>
|
|
34
|
+
<a id="bar-fediverse" href="./" title=" Open the client and read your timeline" aria-current="page">visit</a>
|
|
35
|
+
<a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage</a>
|
|
36
|
+
</span>
|
|
37
|
+
<!-- Notices from whoever runs this site. Shown only when the site has any
|
|
38
|
+
(notices-bar.js asks /api/notices); the count is how many are new here. -->
|
|
39
|
+
<button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
|
|
40
|
+
title=" Notices from the operator of this site">🔔<span id="bar-notices-count" aria-live="polite"></span></button>
|
|
33
41
|
</nav>
|
|
34
42
|
|
|
35
43
|
<main>
|
|
@@ -38,5 +46,24 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
|
|
|
38
46
|
|
|
39
47
|
<script src="../bar.js"></script>
|
|
40
48
|
<script src="client.js"></script>
|
|
49
|
+
|
|
50
|
+
<!-- The notices: the list, and one notice open. Declared here and filled by
|
|
51
|
+
notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
|
|
52
|
+
<dialog id="notices-list" aria-labelledby="notices-title">
|
|
53
|
+
<section>
|
|
54
|
+
<h2 id="notices-title">Notices</h2>
|
|
55
|
+
<ul id="notices-items"></ul>
|
|
56
|
+
<p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
|
|
57
|
+
</section>
|
|
58
|
+
</dialog>
|
|
59
|
+
<dialog id="notice-view" aria-labelledby="notice-view-title">
|
|
60
|
+
<section>
|
|
61
|
+
<h2 id="notice-view-title"></h2>
|
|
62
|
+
<p class="when" id="notice-view-when"></p>
|
|
63
|
+
<div id="notice-view-body"></div>
|
|
64
|
+
<p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
|
|
65
|
+
</section>
|
|
66
|
+
</dialog>
|
|
67
|
+
<script src="../notices-bar.js"></script>
|
|
41
68
|
</body>
|
|
42
69
|
</html>
|
package/web/admin/gateway.js
CHANGED
|
@@ -18,12 +18,31 @@ async function refreshGateway() {
|
|
|
18
18
|
GATEWAY_WORD.textContent = frontName ? `${host} — publishing as @${frontName}@${host}` : host;
|
|
19
19
|
GW_OPEN_ATTACH.hidden = true;
|
|
20
20
|
GW_OPEN_DETACH.hidden = false;
|
|
21
|
+
// The account's standing at the gateway — paused, closed. The browser
|
|
22
|
+
// build's agent reports it; a DeviceAgent's does not, and the pause and
|
|
23
|
+
// close controls stay hidden.
|
|
24
|
+
const st = g.standing;
|
|
25
|
+
const known = !!st && (st.status === 200 || st.status === 410);
|
|
26
|
+
$('gateway-pause').hidden = !known || !!st.closed || !!st.paused;
|
|
27
|
+
$('gateway-resume').hidden = !known || !!st.closed || !st.paused;
|
|
28
|
+
$('gateway-close').hidden = !known || !!st.closed;
|
|
29
|
+
if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
|
|
30
|
+
else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
|
|
21
31
|
} else {
|
|
22
32
|
GATEWAY_WORD.textContent = '';
|
|
23
33
|
GW_OPEN_ATTACH.hidden = false;
|
|
24
34
|
GW_OPEN_DETACH.hidden = true;
|
|
35
|
+
for (const id of ['gateway-pause', 'gateway-resume', 'gateway-close']) $(id).hidden = true;
|
|
25
36
|
}
|
|
26
37
|
}
|
|
38
|
+
const setPausedAtGateway = async (paused) => {
|
|
39
|
+
const r = await write('/gateway/pause', { paused },
|
|
40
|
+
paused ? 'paused — posts sent to you are not kept until you resume; follows still arrive'
|
|
41
|
+
: 'resumed — posts sent to you are kept again');
|
|
42
|
+
if (r) refreshGateway();
|
|
43
|
+
};
|
|
44
|
+
$('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
|
|
45
|
+
$('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
|
|
27
46
|
const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
|
|
28
47
|
function gwPreviews() {
|
|
29
48
|
$('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
|
package/web/admin/index.html
CHANGED
|
@@ -165,7 +165,7 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
165
165
|
fieldset { border-color: #555; } .rows li { border-color: #444; }
|
|
166
166
|
}
|
|
167
167
|
</style>
|
|
168
|
-
<link rel="stylesheet" href="bar.css?v=
|
|
168
|
+
<link rel="stylesheet" href="bar.css?v=4">
|
|
169
169
|
<link rel="stylesheet" href="window.css">
|
|
170
170
|
</head>
|
|
171
171
|
<body>
|
|
@@ -175,8 +175,16 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
175
175
|
<h1 class="name">FediPod</h1>
|
|
176
176
|
<select id="actor-pick" aria-label="Local actors"
|
|
177
177
|
title=" Every actor on this machine; choosing one goes to its record"></select>
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
<!-- The account group, styled like the client group beside it: one label,
|
|
179
|
+
then the places to go. The browser build adds "sign out" to it. -->
|
|
180
|
+
<span id="account-pick"><span id="account-now">account:</span>
|
|
181
|
+
<a id="bar-fediverse" href="client/" title=" Open the client and read your timeline">visit</a>
|
|
182
|
+
<a id="bar-manage" href="./" title=" The record: what this actor is, and what you can do to it" aria-current="page">manage</a>
|
|
183
|
+
</span>
|
|
184
|
+
<!-- Notices from whoever runs this site. Shown only when the site has any
|
|
185
|
+
(notices-bar.js asks /api/notices); the count is how many are new here. -->
|
|
186
|
+
<button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
|
|
187
|
+
title=" Notices from the operator of this site">🔔<span id="bar-notices-count" aria-live="polite"></span></button>
|
|
180
188
|
</nav>
|
|
181
189
|
<main id="page">
|
|
182
190
|
|
|
@@ -321,6 +329,14 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
321
329
|
title=" Attach this account to a mail-filtering gateway">Attach to a gateway</button>
|
|
322
330
|
<button type="button" id="gateway-open-detach" class="inline danger" hidden
|
|
323
331
|
title=" Go back to your pod's own inbox">Detach from gateway</button>
|
|
332
|
+
<!-- Shown only when the agent reports the account's standing at the gateway
|
|
333
|
+
(the browser build does; a DeviceAgent's admin does not). -->
|
|
334
|
+
<button type="button" id="gateway-pause" class="inline" hidden
|
|
335
|
+
title=" Stop keeping the posts sent to you until you resume; follows still arrive">Pause my account</button>
|
|
336
|
+
<button type="button" id="gateway-resume" class="inline" hidden
|
|
337
|
+
title=" Keep the posts sent to you again">Resume my account</button>
|
|
338
|
+
<button type="button" id="gateway-close" class="inline danger" hidden data-confirm="close-address"
|
|
339
|
+
title=" Close your address at this gateway for good; nothing on your pod is touched">Close this address</button>
|
|
324
340
|
</span>
|
|
325
341
|
|
|
326
342
|
<section id="pane-group" hidden>
|
|
@@ -516,6 +532,14 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
516
532
|
<label for="confirm-handle">Type the handle to confirm</label>
|
|
517
533
|
<input type="text" id="confirm-handle" autocomplete="off">
|
|
518
534
|
</div>
|
|
535
|
+
<div id="warn-close-address" class="warn" hidden>
|
|
536
|
+
<p><strong>This cannot be undone.</strong> Your address at the gateway is closed for good:
|
|
537
|
+
mail to it is turned away, other servers drop the account the next time they look, and
|
|
538
|
+
nobody can take the name. Nothing on your pod is touched — your posts, key and data stay
|
|
539
|
+
where they are.</p>
|
|
540
|
+
<label for="confirm-handle-close">Type the handle to confirm</label>
|
|
541
|
+
<input type="text" id="confirm-handle-close" autocomplete="off">
|
|
542
|
+
</div>
|
|
519
543
|
<!-- "Move private data" (POST /state-move) had a panel here and no way to
|
|
520
544
|
open it: nothing in this page has ever carried data-confirm="move-state",
|
|
521
545
|
so the panel was only ever hidden. Removed 2026-09-09 rather than left as
|
|
@@ -556,5 +580,24 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
556
580
|
<script src="group.js"></script>
|
|
557
581
|
<script src="upkeep.js"></script>
|
|
558
582
|
<script src="gateway.js"></script>
|
|
583
|
+
|
|
584
|
+
<!-- The notices: the list, and one notice open. Declared here and filled by
|
|
585
|
+
notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
|
|
586
|
+
<dialog id="notices-list" aria-labelledby="notices-title">
|
|
587
|
+
<section>
|
|
588
|
+
<h2 id="notices-title">Notices</h2>
|
|
589
|
+
<ul id="notices-items"></ul>
|
|
590
|
+
<p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
|
|
591
|
+
</section>
|
|
592
|
+
</dialog>
|
|
593
|
+
<dialog id="notice-view" aria-labelledby="notice-view-title">
|
|
594
|
+
<section>
|
|
595
|
+
<h2 id="notice-view-title"></h2>
|
|
596
|
+
<p class="when" id="notice-view-when"></p>
|
|
597
|
+
<div id="notice-view-body"></div>
|
|
598
|
+
<p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
|
|
599
|
+
</section>
|
|
600
|
+
</dialog>
|
|
601
|
+
<script src="notices-bar.js"></script>
|
|
559
602
|
</body>
|
|
560
603
|
</html>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// notices-bar.js — the notices bell in the bar, on every page of ours.
|
|
2
|
+
//
|
|
3
|
+
// Whoever runs the site writes notices (the /notices page, admin only); every
|
|
4
|
+
// signed-in account sees the bell. This asks /api/notices once per page load,
|
|
5
|
+
// hides the bell where the site has none to offer (a DeviceAgent's own pages
|
|
6
|
+
// answer no such thing), lists the titles, and opens one notice at a time.
|
|
7
|
+
// Which notices this browser has already opened is this browser's business
|
|
8
|
+
// alone, so it lives in localStorage and nowhere else.
|
|
9
|
+
//
|
|
10
|
+
// Every element here is declared in the page's markup; this fills them.
|
|
11
|
+
(() => {
|
|
12
|
+
const bell = document.getElementById('bar-notices');
|
|
13
|
+
const list = document.getElementById('notices-list');
|
|
14
|
+
const view = document.getElementById('notice-view');
|
|
15
|
+
if (!bell || !list || !view) return;
|
|
16
|
+
const $ = (id) => document.getElementById(id);
|
|
17
|
+
const SEEN = 'fedipod-notices-seen';
|
|
18
|
+
const readSeen = () => { try { return JSON.parse(localStorage.getItem(SEEN) || '[]'); } catch { return []; } };
|
|
19
|
+
const writeSeen = (ids) => { try { localStorage.setItem(SEEN, JSON.stringify(ids.slice(-200))); } catch { /* keeps no site data */ } };
|
|
20
|
+
|
|
21
|
+
const when = (iso) => { try { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { return ''; } };
|
|
22
|
+
|
|
23
|
+
// A notice's body is plain text: paragraphs at blank lines, and a bare
|
|
24
|
+
// https link becomes a link. Nothing in it is ever read as markup.
|
|
25
|
+
const renderBody = (el, text) => {
|
|
26
|
+
el.textContent = '';
|
|
27
|
+
for (const para of String(text || '').split(/\n\s*\n/u)) {
|
|
28
|
+
const p = document.createElement('p');
|
|
29
|
+
for (const part of para.split(/(https?:\/\/[^\s<>"']+)/u)) {
|
|
30
|
+
if (/^https?:\/\//u.test(part)) {
|
|
31
|
+
const a = document.createElement('a');
|
|
32
|
+
a.href = part; a.textContent = part; a.target = '_blank'; a.rel = 'noopener';
|
|
33
|
+
p.append(a);
|
|
34
|
+
} else p.append(part);
|
|
35
|
+
}
|
|
36
|
+
el.append(p);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let notices = [];
|
|
41
|
+
const render = () => {
|
|
42
|
+
const seen = new Set(readSeen());
|
|
43
|
+
const fresh = notices.filter((n) => !seen.has(n.id)).length;
|
|
44
|
+
$('bar-notices-count').textContent = fresh ? String(fresh) : '';
|
|
45
|
+
bell.dataset.new = String(fresh);
|
|
46
|
+
bell.setAttribute('aria-label', fresh ? `Notices, ${fresh} new` : 'Notices');
|
|
47
|
+
const ul = $('notices-items');
|
|
48
|
+
ul.textContent = '';
|
|
49
|
+
if (!notices.length) {
|
|
50
|
+
const p = document.createElement('p'); p.className = 'none'; p.textContent = 'No notices.';
|
|
51
|
+
ul.append(p);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const n of notices) {
|
|
55
|
+
const li = document.createElement('li');
|
|
56
|
+
if (!seen.has(n.id)) li.classList.add('new');
|
|
57
|
+
const b = document.createElement('button');
|
|
58
|
+
b.type = 'button'; b.className = 'notice-open'; b.textContent = n.title;
|
|
59
|
+
b.addEventListener('click', () => open(n));
|
|
60
|
+
const w = document.createElement('span'); w.className = 'when'; w.textContent = when(n.at);
|
|
61
|
+
li.append(b, w);
|
|
62
|
+
ul.append(li);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const open = (n) => {
|
|
67
|
+
$('notice-view-title').textContent = n.title;
|
|
68
|
+
$('notice-view-when').textContent = when(n.at);
|
|
69
|
+
renderBody($('notice-view-body'), n.body);
|
|
70
|
+
const seen = readSeen();
|
|
71
|
+
if (!seen.includes(n.id)) { seen.push(n.id); writeSeen(seen); }
|
|
72
|
+
render();
|
|
73
|
+
list.close();
|
|
74
|
+
view.showModal();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
bell.addEventListener('click', () => { render(); list.showModal(); });
|
|
78
|
+
$('notices-close').addEventListener('click', () => list.close());
|
|
79
|
+
$('notice-view-close').addEventListener('click', () => { view.close(); list.showModal(); });
|
|
80
|
+
|
|
81
|
+
fetch(location.origin + '/api/notices', { headers: { accept: 'application/json' } })
|
|
82
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
83
|
+
.then((j) => {
|
|
84
|
+
if (!j || !Array.isArray(j.notices)) return;
|
|
85
|
+
notices = j.notices;
|
|
86
|
+
render();
|
|
87
|
+
bell.hidden = false;
|
|
88
|
+
})
|
|
89
|
+
.catch(() => { /* no site notices here; the bell stays hidden */ });
|
|
90
|
+
})();
|
package/web/admin/record.js
CHANGED
|
@@ -94,6 +94,7 @@ function resetConfirm() {
|
|
|
94
94
|
for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
|
|
95
95
|
$('confirm-handle').value = '';
|
|
96
96
|
$('confirm-handle-move').value = '';
|
|
97
|
+
$('confirm-handle-close').value = '';
|
|
97
98
|
$('move-target').value = '';
|
|
98
99
|
}
|
|
99
100
|
function closePanels(keep = null) {
|
|
@@ -59,8 +59,16 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
59
59
|
<nav id="bar" aria-label="Site">
|
|
60
60
|
<h1 class="name">FediPod</h1>
|
|
61
61
|
<span id="bar-handle"></span>
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
<!-- The account group, styled like the client group beside it: one label,
|
|
63
|
+
then the places to go. The browser build adds "sign out" to it. -->
|
|
64
|
+
<span id="account-pick"><span id="account-now">account:</span>
|
|
65
|
+
<a id="bar-fediverse" href="../client/" title=" Open the client and read your timeline">visit</a>
|
|
66
|
+
<a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage</a>
|
|
67
|
+
</span>
|
|
68
|
+
<!-- Notices from whoever runs this site. Shown only when the site has any
|
|
69
|
+
(notices-bar.js asks /api/notices); the count is how many are new here. -->
|
|
70
|
+
<button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
|
|
71
|
+
title=" Notices from the operator of this site">🔔<span id="bar-notices-count" aria-live="polite"></span></button>
|
|
64
72
|
<a id="bar-add" href="../?new=1" title=" Set up another actor on this machine, with its own pod" aria-current="page">add new account</a>
|
|
65
73
|
</nav>
|
|
66
74
|
<main id="page">
|
|
@@ -210,5 +218,24 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
210
218
|
<script src="../bar.js"></script>
|
|
211
219
|
<script src="../common.js"></script>
|
|
212
220
|
<script src="setup.js"></script>
|
|
221
|
+
|
|
222
|
+
<!-- The notices: the list, and one notice open. Declared here and filled by
|
|
223
|
+
notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
|
|
224
|
+
<dialog id="notices-list" aria-labelledby="notices-title">
|
|
225
|
+
<section>
|
|
226
|
+
<h2 id="notices-title">Notices</h2>
|
|
227
|
+
<ul id="notices-items"></ul>
|
|
228
|
+
<p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
|
|
229
|
+
</section>
|
|
230
|
+
</dialog>
|
|
231
|
+
<dialog id="notice-view" aria-labelledby="notice-view-title">
|
|
232
|
+
<section>
|
|
233
|
+
<h2 id="notice-view-title"></h2>
|
|
234
|
+
<p class="when" id="notice-view-when"></p>
|
|
235
|
+
<div id="notice-view-body"></div>
|
|
236
|
+
<p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
|
|
237
|
+
</section>
|
|
238
|
+
</dialog>
|
|
239
|
+
<script src="../notices-bar.js"></script>
|
|
213
240
|
</body>
|
|
214
241
|
</html>
|
package/web/admin/upkeep.js
CHANGED
|
@@ -76,6 +76,9 @@ const LIFECYCLE = {
|
|
|
76
76
|
retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
|
|
77
77
|
move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
|
|
78
78
|
done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
|
|
79
|
+
// The address at the gateway, not the identity: the pod keeps everything.
|
|
80
|
+
'close-address': { path: '/gateway/close', title: 'Close this address', go: 'Close it', danger: true, focus: 'confirm-handle-close',
|
|
81
|
+
done: (r) => (r.closed ? 'closed — this address is gone for good; everything on your pod is untouched' : 'not closed') },
|
|
79
82
|
};
|
|
80
83
|
let pending = null;
|
|
81
84
|
|
|
@@ -119,7 +122,8 @@ $('confirm-form').addEventListener('submit', async (ev) => {
|
|
|
119
122
|
const what = pending;
|
|
120
123
|
const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
|
|
121
124
|
: what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
|
|
122
|
-
: {}
|
|
125
|
+
: what === 'close-address' ? { confirm: $('confirm-handle-close').value.trim() }
|
|
126
|
+
: {};
|
|
123
127
|
if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
|
|
124
128
|
$('confirm-go').disabled = true;
|
|
125
129
|
say(`${what} — this talks to the pod and to other servers, so it takes a moment`);
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export const ADMIN_PATHS = new Set([
|
|
|
33
33
|
'/deadletter', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
|
|
34
34
|
'/atproto', '/atproto/connect', '/atproto/disconnect',
|
|
35
35
|
'/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
|
|
36
|
+
'/gateway/pause', '/gateway/close',
|
|
36
37
|
'/fediacct/connect', '/fediacct/disconnect', '/fediacct/callback',
|
|
37
38
|
]);
|
|
38
39
|
|
|
@@ -138,6 +139,10 @@ export class AdminFacade {
|
|
|
138
139
|
configured: !!(g && g.url), url: g?.url || null, webId: g?.webId || null,
|
|
139
140
|
frontActor: g?.frontActor || null, mode: g?.mode || 'off', hasSecret: !!g?.hmacSecret,
|
|
140
141
|
stats: a.store.read('gateway-stats.json', { verified: 0, unverified: 0, lastAt: null }),
|
|
142
|
+
// The account's standing at the gateway — paused, closed — as the
|
|
143
|
+
// gateway last said it (agent.mjs tellGateway). The record page
|
|
144
|
+
// shows its pause and close controls only when this is here.
|
|
145
|
+
standing: a.gatewayStanding || null,
|
|
141
146
|
});
|
|
142
147
|
}
|
|
143
148
|
case '/deadletter': return json(200, { items: a.store.getDeadLetters() });
|
|
@@ -264,6 +269,25 @@ export class AdminFacade {
|
|
|
264
269
|
return json(200, { ok: true, mode: a.status().mode });
|
|
265
270
|
}
|
|
266
271
|
|
|
272
|
+
// The account's standing at its gateway: paused by its owner, or closed
|
|
273
|
+
// for good (front-core: accounts that go quiet). Both go to the gateway
|
|
274
|
+
// with the pod session as proof, and its answer is the new standing.
|
|
275
|
+
case '/gateway/pause': {
|
|
276
|
+
if (typeof body.paused !== 'boolean') return json(400, { error: 'paused must be true or false' });
|
|
277
|
+
if (!a.pauseAtGateway || !a.gatewayApi) return json(501, { error: 'this account is not at a gateway' });
|
|
278
|
+
const r = await a.pauseAtGateway(body.paused);
|
|
279
|
+
if (!r) return json(502, { error: 'the gateway could not be reached' });
|
|
280
|
+
return json(r.status === 200 ? 200 : r.status, r);
|
|
281
|
+
}
|
|
282
|
+
case '/gateway/close': {
|
|
283
|
+
const handle = String(a.store.getConfig()?.handle || '').toLowerCase();
|
|
284
|
+
if (!handle || String(body.confirm || '').toLowerCase() !== handle) return json(400, { error: 'type the handle to confirm' });
|
|
285
|
+
if (!a.closeAtGateway || !a.gatewayApi) return json(501, { error: 'this account is not at a gateway' });
|
|
286
|
+
const r = await a.closeAtGateway();
|
|
287
|
+
if (!r) return json(502, { error: 'the gateway could not be reached' });
|
|
288
|
+
return json(r.status === 200 ? 200 : r.status, r);
|
|
289
|
+
}
|
|
290
|
+
|
|
267
291
|
// Going quiet, and coming back. The record page's active/parked select.
|
|
268
292
|
case '/park': {
|
|
269
293
|
if (!await a.requestTakeover?.()) return json(503, { error: 'another device is active for this pod — park from there' });
|
package/web/app/agent.mjs
CHANGED
|
@@ -137,19 +137,46 @@ export class BrowserAgent {
|
|
|
137
137
|
return true;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// One call to the gateway's owner API, proved with the pod session. What
|
|
141
|
+
// comes back on 200 or 410 is the account's standing there, kept for the
|
|
142
|
+
// manage page; anything else is logged and forgotten.
|
|
143
|
+
async tellGateway(what, body) {
|
|
144
|
+
if (!this.gatewayApi) return null;
|
|
145
|
+
let res;
|
|
146
|
+
try {
|
|
147
|
+
res = await this.sessionFetch(`${this.gatewayApi}/${what}`, {
|
|
148
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
149
|
+
});
|
|
150
|
+
} catch (e) { this.log(`gateway ${what}: ${e.message}`); return null; }
|
|
151
|
+
const json = await res.json().catch(() => ({}));
|
|
152
|
+
const out = { status: res.status, ...json };
|
|
153
|
+
if (res.status === 200 || res.status === 410) this.gatewayStanding = out;
|
|
154
|
+
else this.log(`gateway ${what}: ${res.status} ${json.error || ''}`);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
openAtGateway() { return this.tellGateway('open', { handle: this.doorKey }); }
|
|
158
|
+
pauseAtGateway(paused) { return this.tellGateway('pause', { handle: this.doorKey, paused: !!paused }); }
|
|
159
|
+
closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
|
|
160
|
+
static OPEN_EVERY_MS = 60 * 60_000;
|
|
161
|
+
|
|
140
162
|
// Become the active agent: renew the lease, then start what a viewer skips —
|
|
141
163
|
// publish the face, drain the inbox, run the mirrors.
|
|
142
164
|
async goActive() {
|
|
143
165
|
this.viewer = false;
|
|
166
|
+
// Reading here is being here: the gateway hears so once an hour.
|
|
167
|
+
clearInterval(this._openTimer);
|
|
168
|
+
this._openTimer = setInterval(() => { this.openAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
|
|
144
169
|
this.lease.onLost = () => this.demote();
|
|
145
170
|
this.lease.startRenewal();
|
|
146
171
|
try {
|
|
147
|
-
// Forced, not revalidated
|
|
148
|
-
// writing
|
|
149
|
-
// the store is write-through — so the first write from here
|
|
150
|
-
// whole document back over newer state.
|
|
151
|
-
//
|
|
152
|
-
|
|
172
|
+
// Forced, not revalidated, when this device WATCHED first: the active
|
|
173
|
+
// one was writing, our cache is however stale the last viewer poll left
|
|
174
|
+
// it, and the store is write-through — so the first write from here
|
|
175
|
+
// would push a whole document back over newer state. A device that
|
|
176
|
+
// booted straight into acting read everything a moment ago; asking
|
|
177
|
+
// again is one revalidation, not a second download of every document.
|
|
178
|
+
await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
|
|
179
|
+
this._watched = false;
|
|
153
180
|
// Own posts the outbox names and the timeline index lacks come back
|
|
154
181
|
// here, before anything acts on the index.
|
|
155
182
|
await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
|
|
@@ -177,8 +204,10 @@ export class BrowserAgent {
|
|
|
177
204
|
demote() {
|
|
178
205
|
if (this.viewer) return;
|
|
179
206
|
this.viewer = true;
|
|
207
|
+
this._watched = true;
|
|
180
208
|
this.log('another device took over — read-only here');
|
|
181
209
|
this.lease.stopRenewal();
|
|
210
|
+
clearInterval(this._openTimer); this._openTimer = null;
|
|
182
211
|
this.intake?.stop?.();
|
|
183
212
|
// The delivery queue as well. Its timer starts in the Deliverer's
|
|
184
213
|
// constructor and nothing here ever switched it off, so a demoted device
|
|
@@ -322,6 +351,29 @@ export class BrowserAgent {
|
|
|
322
351
|
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
323
352
|
});
|
|
324
353
|
|
|
354
|
+
// The gateway this account's mail comes through, and the account's own
|
|
355
|
+
// standing there — paused, closed, how much arrived unread (front-core:
|
|
356
|
+
// accounts that go quiet). The gateway is told on every sign-in and
|
|
357
|
+
// hourly while this device is active: that is what keeps an account
|
|
358
|
+
// somebody reads from counting as a quiet one. Only a closed address
|
|
359
|
+
// stops the boot; a gateway that cannot be reached is no reason to
|
|
360
|
+
// refuse a sign-in.
|
|
361
|
+
// At the page's own origin, as the relay is: a page on the test alias
|
|
362
|
+
// asked the real site instead and could not get past its preflight.
|
|
363
|
+
this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
|
|
364
|
+
this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
|
|
365
|
+
this.gatewayStanding = null;
|
|
366
|
+
const standing = await this.openAtGateway();
|
|
367
|
+
if (standing?.status === 410) {
|
|
368
|
+
const host = (() => { try { return new URL(this.gatewayApi).host; } catch { return 'the gateway'; } })();
|
|
369
|
+
const address = this.doorKey.includes('@') ? `@${this.doorKey}` : `@${this.doorKey}@${host}`;
|
|
370
|
+
const why = standing.closedBy === 'owner' ? 'you closed it' : 'nothing opened it for six months';
|
|
371
|
+
const e = new Error(`${address} is closed at ${host}: ${why}, and a closed address does not come back.`
|
|
372
|
+
+ ` Everything on your pod is untouched. The sign-in used here was ${webId}.`);
|
|
373
|
+
e.code = 'address-closed';
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
376
|
+
|
|
325
377
|
this.publisher = new Publisher({
|
|
326
378
|
config: this.store.getConfig(), remote: this.remote, store: this.store,
|
|
327
379
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
|
|
@@ -428,6 +480,7 @@ export class BrowserAgent {
|
|
|
428
480
|
// The lease decides: this device ACTS on the pod, or reads it read-only.
|
|
429
481
|
this.viewer = !(await this.lease.acquire());
|
|
430
482
|
if (this.viewer) {
|
|
483
|
+
this._watched = true;
|
|
431
484
|
this.log(`read-only viewer: another device is active on @${config.handle}`);
|
|
432
485
|
this.startViewerPoll(); // reload the feed, and promote if the lease frees
|
|
433
486
|
return;
|
package/web/app/boot.mjs
CHANGED
|
@@ -179,6 +179,7 @@ async function issuerForPod(pod) {
|
|
|
179
179
|
async function podForFrontedAddress(handle) {
|
|
180
180
|
const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
|
|
181
181
|
{ headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
|
|
182
|
+
if (res?.status === 410) throw new Error(`@${handle}@${location.host} is closed: nothing on the pod behind it was touched, but the address is gone for good.`);
|
|
182
183
|
if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
|
|
183
184
|
const doc = await res.json().catch(() => ({}));
|
|
184
185
|
const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
|
|
@@ -352,6 +353,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
352
353
|
'no-account-here': { title: 'No FediPod account in that pod', retry: 'Create one on this pod', go: () => showIdentity() },
|
|
353
354
|
'no-account': { title: 'No FediPod account in that pod', retry: 'Create one on this pod', go: () => showIdentity() },
|
|
354
355
|
'device-account': { title: 'This account is run from a device', retry: 'Use another pod', go: signIn },
|
|
356
|
+
'address-closed': { title: 'This address is closed', retry: 'Sign in with another account', go: signIn },
|
|
355
357
|
}[e.code];
|
|
356
358
|
|
|
357
359
|
// A sign-in the pod would not take: renew it here, and if the pod will
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { Deliverer } from '../../lib/core/deliver.mjs';
|
|
11
11
|
import { sign } from './shims/fedify-sig.mjs';
|
|
12
12
|
|
|
13
|
+
// What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
|
|
14
|
+
const RELAY_MAX_REQUESTS = 20;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* The name the front keys this account's row by, read off its door inbox:
|
|
15
18
|
* `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
|
|
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
|
|
|
30
33
|
this.relayUrl = opts.relayUrl; // <front>/api/relay
|
|
31
34
|
this.handle = opts.handle;
|
|
32
35
|
this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
|
|
36
|
+
this.batchSize = RELAY_MAX_REQUESTS;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
// Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
|
|
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
|
|
|
40
44
|
// never saw the document it asked for: a Follow from anyone new was rejected
|
|
41
45
|
// with "actor fetch failed", and nothing needing a lookup could be ingested.
|
|
42
46
|
async signedFetch(url, init = {}) {
|
|
47
|
+
const req = await this._signedRequest(url, init);
|
|
48
|
+
const [r0] = await this._relay([req]);
|
|
49
|
+
return this._outcome(r0, url, init.method || 'GET');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A fan-out in one call: the relay takes a list, so a post to twenty
|
|
53
|
+
// followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
|
|
54
|
+
async deliverManyNow(targets) {
|
|
55
|
+
const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
|
|
56
|
+
method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
|
|
57
|
+
})));
|
|
58
|
+
let results;
|
|
59
|
+
try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
|
|
60
|
+
return targets.map((t, i) => {
|
|
61
|
+
try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
|
|
62
|
+
catch (error) { return { error }; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Signed here, sent verbatim by the relay. Every signed header goes along,
|
|
67
|
+
// `accept` included: the signature covers it, so a relay request missing it
|
|
68
|
+
// carries an invalid signature — and a read without it gets the HTML page
|
|
69
|
+
// instead of the document.
|
|
70
|
+
async _signedRequest(url, init = {}) {
|
|
43
71
|
const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
|
|
44
72
|
const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
|
|
45
|
-
|
|
46
|
-
// covers it, so a relay request missing it carries an invalid signature —
|
|
47
|
-
// and a read without it gets the HTML page instead of the document.
|
|
48
|
-
const relayReq = {
|
|
73
|
+
return {
|
|
49
74
|
url: s.url, method: s.method, body,
|
|
50
75
|
headers: {
|
|
51
76
|
date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
|
|
52
77
|
'content-type': s.headers['content-type'], signature: s.headers.signature,
|
|
53
78
|
},
|
|
54
79
|
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One relay call for a list of requests; the results in the same order.
|
|
83
|
+
// The relay's OWN answer, apart from the recipients': unreachable and a
|
|
84
|
+
// refusal are hiccups the queue retries. Its 404 is not — it says this
|
|
85
|
+
// account has no row here, and no retry changes that. It used to be read as
|
|
86
|
+
// a hiccup too, and a tab whose account the site did not know retried its
|
|
87
|
+
// deliveries every minute for three days.
|
|
88
|
+
async _relay(requests) {
|
|
55
89
|
let res;
|
|
56
90
|
try {
|
|
57
91
|
res = await this.sessionFetch(this.relayUrl, {
|
|
58
92
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
59
|
-
body: JSON.stringify({ handle: this.handle, requests
|
|
93
|
+
body: JSON.stringify({ handle: this.handle, requests }),
|
|
60
94
|
});
|
|
61
95
|
} catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
|
|
96
|
+
if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
|
|
62
97
|
if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
|
|
63
98
|
const out = await res.json().catch(() => ({}));
|
|
64
|
-
|
|
99
|
+
return Array.isArray(out.results) ? out.results : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the far server answered, as the Node deliverer would have seen it:
|
|
103
|
+
// a Response for a read, a thrown error carrying the status for a refusal.
|
|
104
|
+
_outcome(r0, url, method) {
|
|
65
105
|
const status = r0.status || 0;
|
|
66
106
|
if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
|
|
67
107
|
if (status >= 400) {
|
|
68
|
-
const err = new Error(`${
|
|
108
|
+
const err = new Error(`${method} ${url} → ${status}`); err.status = status;
|
|
69
109
|
// The receiving server's own answer to "when should I try again", carried
|
|
70
110
|
// through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
|
|
71
111
|
// what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
|