fedipod 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gateway.md +37 -0
- package/gui.md +2 -2
- package/lib/gateway/front-core.mjs +34 -14
- package/lib/gateway/gateway-core.mjs +15 -6
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +184 -0
- package/package.json +1 -1
- 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 +48 -0
- package/web/app/boot.mjs +2 -0
- package/web/app/dist/boot.js +3 -1
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +86 -1
- package/web/app/dist/sw.js.map +3 -3
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
package/web/admin/bar.css
CHANGED
|
@@ -62,16 +62,32 @@ body { background: var(--surface); }
|
|
|
62
62
|
quieter than the links; the one in use is marked `aria-current="true"` — the
|
|
63
63
|
current item of a set, which on the record page is not the current PAGE — so
|
|
64
64
|
it needs its own mark rather than borrowing the rule above. */
|
|
65
|
-
#bar #client-pick { display: inline-flex; gap: .6rem; align-items: baseline;
|
|
65
|
+
#bar #client-pick, #bar #account-pick { display: inline-flex; gap: .6rem; align-items: baseline;
|
|
66
66
|
white-space: nowrap; margin-left: 1rem; }
|
|
67
|
-
#bar #client-now { opacity: .75; }
|
|
67
|
+
#bar #client-now, #bar #account-now { opacity: .75; }
|
|
68
|
+
/* The notices bell: the one filled control in the bar, because it is the one
|
|
69
|
+
thing there that is not a place to go but something to be told. The count is
|
|
70
|
+
how many notices this browser has not opened yet; with none it is quiet. */
|
|
71
|
+
#bar #bar-notices { display: inline-flex; align-items: center; gap: .35rem; margin-left: 1rem;
|
|
72
|
+
padding: .3rem .7rem; border-radius: 999px; font-size: 1.05rem; line-height: 1;
|
|
73
|
+
background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn); }
|
|
74
|
+
/* `display` on an id rule outranks the browser's own [hidden] rule, and the
|
|
75
|
+
shell has no !important of its own, so the bell hid on the record page and
|
|
76
|
+
showed on the client with nothing to show. Said here, once, for every page. */
|
|
77
|
+
#bar #bar-notices[hidden] { display: none; }
|
|
78
|
+
#bar #bar-notices:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
79
|
+
#bar #bar-notices:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
80
|
+
#bar #bar-notices-count { font-size: .9rem; font-weight: 700; min-width: 1.3em; text-align: center;
|
|
81
|
+
padding: .05rem .4rem; border-radius: 999px; background: var(--btn-text); color: var(--btn); }
|
|
82
|
+
#bar #bar-notices-count:empty { display: none; }
|
|
83
|
+
#bar #bar-notices[data-new="0"] { background: var(--field-bg); color: var(--ink); border-color: var(--field-edge); }
|
|
68
84
|
#bar a[aria-current="true"] {
|
|
69
85
|
font-weight: 700; text-decoration: underline; text-underline-offset: .3rem; }
|
|
70
86
|
|
|
71
87
|
/* The two-clients notice: said once, to somebody who knew this page when it
|
|
72
88
|
had one client. Sized and placed rather than left to the browser's default,
|
|
73
89
|
which stretched it edge to edge and put it over the very links it names. */
|
|
74
|
-
#client-news {
|
|
90
|
+
#client-news, #notices-list, #notice-view {
|
|
75
91
|
/* Low rather than centred: it points at the links in the bar, so the bar has
|
|
76
92
|
to stay in view while it is read. */
|
|
77
93
|
max-width: 34rem; margin: auto auto 2.5rem; padding: 1.5rem 1.7rem;
|
|
@@ -80,13 +96,31 @@ body { background: var(--surface); }
|
|
|
80
96
|
font: 1rem/1.55 var(--font);
|
|
81
97
|
box-shadow: 0 .6rem 2rem rgba(0, 0, 0, .25);
|
|
82
98
|
}
|
|
83
|
-
#client-news::backdrop { background: rgba(0, 0, 0, .45); }
|
|
84
|
-
#client-news h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
|
|
85
|
-
#client-news p { margin: 0 0 1.2rem; }
|
|
86
|
-
#client-news p:last-child { margin: 0; }
|
|
87
|
-
#client-news button {
|
|
99
|
+
#client-news::backdrop, #notices-list::backdrop, #notice-view::backdrop { background: rgba(0, 0, 0, .45); }
|
|
100
|
+
#client-news h2, #notices-list h2, #notice-view h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
|
|
101
|
+
#client-news p, #notices-list p, #notice-view p { margin: 0 0 1.2rem; }
|
|
102
|
+
#client-news p:last-child, #notices-list p:last-child, #notice-view p:last-child { margin: 0; }
|
|
103
|
+
#client-news button, #notices-list button, #notice-view button {
|
|
88
104
|
font: inherit; padding: .55rem 1.3rem; border-radius: .35rem; cursor: pointer;
|
|
89
105
|
background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn);
|
|
90
106
|
}
|
|
91
|
-
#client-news button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
92
|
-
#client-news button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
107
|
+
#client-news button:hover, #notices-list button:hover, #notice-view button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
108
|
+
#client-news button:focus-visible, #notices-list button:focus-visible, #notice-view button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
109
|
+
|
|
110
|
+
/* The notices dialogs are centred and never taller than the window: the
|
|
111
|
+
client-news rule above sits low, and a short window pushed a list of
|
|
112
|
+
notices up past the top edge where nothing could reach it. */
|
|
113
|
+
#notices-list, #notice-view { margin: auto; max-height: calc(100dvh - 3rem); overflow: auto; box-sizing: border-box; }
|
|
114
|
+
/* The list of notices: each title a button that opens that notice, the date
|
|
115
|
+
beside it, the ones not yet opened here in bold. */
|
|
116
|
+
#notices-items { list-style: none; margin: 0 0 1.2rem; padding: 0; }
|
|
117
|
+
#notices-items li { display: flex; gap: .8rem; align-items: baseline; padding: .45rem 0;
|
|
118
|
+
border-bottom: 1px solid var(--line); }
|
|
119
|
+
#notices-items li:last-child { border-bottom: 0; }
|
|
120
|
+
#notices-items button.notice-open { flex: 1 1 auto; text-align: left; padding: .1rem 0;
|
|
121
|
+
background: none; border: 0; color: var(--link); text-decoration: underline; text-underline-offset: .2rem; }
|
|
122
|
+
#notices-items li.new button.notice-open { font-weight: 700; }
|
|
123
|
+
#notices-items .when, #notice-view .when { color: var(--sub); font-size: .9rem; white-space: nowrap; }
|
|
124
|
+
#notices-items p.none { color: var(--sub); }
|
|
125
|
+
#notice-view-body p { margin: 0 0 .9rem; }
|
|
126
|
+
#notice-view-body a { color: var(--link); word-break: break-all; }
|
|
@@ -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,10 +137,35 @@ 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 {
|
|
@@ -179,6 +204,7 @@ export class BrowserAgent {
|
|
|
179
204
|
this.viewer = true;
|
|
180
205
|
this.log('another device took over — read-only here');
|
|
181
206
|
this.lease.stopRenewal();
|
|
207
|
+
clearInterval(this._openTimer); this._openTimer = null;
|
|
182
208
|
this.intake?.stop?.();
|
|
183
209
|
// The delivery queue as well. Its timer starts in the Deliverer's
|
|
184
210
|
// constructor and nothing here ever switched it off, so a demoted device
|
|
@@ -322,6 +348,28 @@ export class BrowserAgent {
|
|
|
322
348
|
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
323
349
|
});
|
|
324
350
|
|
|
351
|
+
// The gateway this account's mail comes through, and the account's own
|
|
352
|
+
// standing there — paused, closed, how much arrived unread (front-core:
|
|
353
|
+
// accounts that go quiet). The gateway is told on every sign-in and
|
|
354
|
+
// hourly while this device is active: that is what keeps an account
|
|
355
|
+
// somebody reads from counting as a quiet one. Only a closed address
|
|
356
|
+
// stops the boot; a gateway that cannot be reached is no reason to
|
|
357
|
+
// refuse a sign-in.
|
|
358
|
+
this.gatewayApi = null;
|
|
359
|
+
try { if (config.gateway?.url) this.gatewayApi = `${new URL(config.gateway.url).origin}/api`; } catch { this.gatewayApi = null; }
|
|
360
|
+
this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
|
|
361
|
+
this.gatewayStanding = null;
|
|
362
|
+
const standing = await this.openAtGateway();
|
|
363
|
+
if (standing?.status === 410) {
|
|
364
|
+
const host = (() => { try { return new URL(this.gatewayApi).host; } catch { return 'the gateway'; } })();
|
|
365
|
+
const address = this.doorKey.includes('@') ? `@${this.doorKey}` : `@${this.doorKey}@${host}`;
|
|
366
|
+
const why = standing.closedBy === 'owner' ? 'you closed it' : 'nothing opened it for six months';
|
|
367
|
+
const e = new Error(`${address} is closed at ${host}: ${why}, and a closed address does not come back.`
|
|
368
|
+
+ ` Everything on your pod is untouched. The sign-in used here was ${webId}.`);
|
|
369
|
+
e.code = 'address-closed';
|
|
370
|
+
throw e;
|
|
371
|
+
}
|
|
372
|
+
|
|
325
373
|
this.publisher = new Publisher({
|
|
326
374
|
config: this.store.getConfig(), remote: this.remote, store: this.store,
|
|
327
375
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
|
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
|
package/web/app/dist/boot.js
CHANGED
|
@@ -33662,6 +33662,7 @@ async function podForFrontedAddress(handle) {
|
|
|
33662
33662
|
`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
|
|
33663
33663
|
{ headers: { accept: "application/jrd+json, application/json" } }
|
|
33664
33664
|
).catch(() => null);
|
|
33665
|
+
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.`);
|
|
33665
33666
|
if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
|
|
33666
33667
|
const doc = await res.json().catch(() => ({}));
|
|
33667
33668
|
const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
|
|
@@ -33809,7 +33810,8 @@ if (typeof document !== "undefined") (async () => {
|
|
|
33809
33810
|
// login and the identity screen lands here.
|
|
33810
33811
|
"no-account-here": { title: "No FediPod account in that pod", retry: "Create one on this pod", go: () => showIdentity() },
|
|
33811
33812
|
"no-account": { title: "No FediPod account in that pod", retry: "Create one on this pod", go: () => showIdentity() },
|
|
33812
|
-
"device-account": { title: "This account is run from a device", retry: "Use another pod", go: signIn }
|
|
33813
|
+
"device-account": { title: "This account is run from a device", retry: "Use another pod", go: signIn },
|
|
33814
|
+
"address-closed": { title: "This address is closed", retry: "Sign in with another account", go: signIn }
|
|
33813
33815
|
}[e.code];
|
|
33814
33816
|
if (e.code === "sign-in-refused" && once("fedipod-renewing")) {
|
|
33815
33817
|
$("loading").hidden = true;
|