fedipod 1.29.2 → 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/device-agent.md +9 -2
- package/gateway.md +43 -4
- package/gui.md +2 -2
- package/lib/core/publisher/index.mjs +3 -0
- package/lib/core/wire.mjs +10 -1
- package/lib/device/cli/commands/setup.mjs +6 -2
- package/lib/device/gateway-move.mjs +58 -0
- package/lib/device/migrate.mjs +3 -1
- package/lib/device/setup.mjs +67 -7
- 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/run-agent.mjs +5 -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 +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 +96 -3
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// notices.mjs — notices from whoever runs the front, to every account here.
|
|
2
|
+
//
|
|
3
|
+
// The operator writes them on the /notices page, proved as the admin the way
|
|
4
|
+
// the roster is; every page of ours asks GET /api/notices once per load and
|
|
5
|
+
// shows a bell when there are any. A notice is a title, a plain-text body and
|
|
6
|
+
// a time. The body is never markup: the pages render it as text.
|
|
7
|
+
//
|
|
8
|
+
// Kept beside the directory, in a store the adapter supplies:
|
|
9
|
+
// listNotices() -> { id: notice } putNotice(id, notice) deleteNotice(id)
|
|
10
|
+
// A front that supplies none has no notices to offer, and says so.
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const TITLE_MAX = 120;
|
|
15
|
+
const BODY_MAX = 4000;
|
|
16
|
+
const NOTICES_EDGE_SECONDS = 60;
|
|
17
|
+
|
|
18
|
+
const clean = (v, max) => String(v ?? '').replace(/\r\n?/gu, '\n').trim().slice(0, max);
|
|
19
|
+
const newest = (a, b) => String(b.at || '').localeCompare(String(a.at || ''));
|
|
20
|
+
|
|
21
|
+
export async function listNotices(ctx) {
|
|
22
|
+
const all = ctx.listNotices ? await ctx.listNotices() : {};
|
|
23
|
+
return Object.values(all || {}).filter(Boolean).sort(newest);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// GET is public: what every account is shown. POST is the admin's alone. The
|
|
27
|
+
// /notices page is where the admin writes them; it signs in like the roster.
|
|
28
|
+
// Returns a response, or null when the path is neither.
|
|
29
|
+
export async function routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound }) {
|
|
30
|
+
// The two admin pages: the roster (who has accounts here) and the notices.
|
|
31
|
+
// Each signs in and calls its API; a deploy with no admin supplies no page.
|
|
32
|
+
const page = pathname === '/notices' ? ctx.noticesPage : pathname === '/roster' ? ctx.adminPage : undefined;
|
|
33
|
+
if (page !== undefined) {
|
|
34
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
35
|
+
if (!page) return notFound();
|
|
36
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: page };
|
|
37
|
+
}
|
|
38
|
+
if (pathname !== '/api/notices') return null;
|
|
39
|
+
const open = { 'access-control-allow-origin': '*' };
|
|
40
|
+
if (request.method === 'OPTIONS') {
|
|
41
|
+
return { status: 204, headers: { ...open, 'access-control-allow-methods': 'GET, OPTIONS',
|
|
42
|
+
'access-control-allow-headers': 'Accept', allow: 'GET, POST, OPTIONS' }, body: null };
|
|
43
|
+
}
|
|
44
|
+
if (request.method === 'GET') {
|
|
45
|
+
if (!ctx.listNotices) return j(501, { error: 'this front keeps no notices' });
|
|
46
|
+
const out = j(200, { notices: await listNotices(ctx) });
|
|
47
|
+
Object.assign(out.headers, open, publicFor(NOTICES_EDGE_SECONDS));
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
51
|
+
if (!ctx.putNotice || !ctx.deleteNotice || !ctx.adminWebId) return j(501, { error: 'this front keeps no notices' });
|
|
52
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
53
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
54
|
+
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
55
|
+
let body;
|
|
56
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
57
|
+
const action = String(body.action || '');
|
|
58
|
+
const all = (ctx.listNotices ? await ctx.listNotices() : {}) || {};
|
|
59
|
+
if (action === 'delete') {
|
|
60
|
+
const id = String(body.id || '');
|
|
61
|
+
if (!all[id]) return j(404, { error: 'no such notice' });
|
|
62
|
+
await ctx.deleteNotice(id);
|
|
63
|
+
console.log(`front: notice ${id} deleted by the admin`);
|
|
64
|
+
return j(200, { ok: true, id, deleted: true });
|
|
65
|
+
}
|
|
66
|
+
if (action !== 'create' && action !== 'update') return j(400, { error: 'action must be create, update or delete' });
|
|
67
|
+
const title = clean(body.title, TITLE_MAX);
|
|
68
|
+
const text = clean(body.body, BODY_MAX);
|
|
69
|
+
if (!title) return j(400, { error: 'a title is required' });
|
|
70
|
+
if (!text) return j(400, { error: 'a body is required' });
|
|
71
|
+
const now = new Date().toISOString();
|
|
72
|
+
let notice;
|
|
73
|
+
if (action === 'create') {
|
|
74
|
+
const id = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
|
|
75
|
+
notice = { id, title, body: text, at: now, updatedAt: now };
|
|
76
|
+
} else {
|
|
77
|
+
const prior = all[String(body.id || '')];
|
|
78
|
+
if (!prior) return j(404, { error: 'no such notice' });
|
|
79
|
+
notice = { ...prior, title, body: text, updatedAt: now };
|
|
80
|
+
}
|
|
81
|
+
await ctx.putNotice(notice.id, notice);
|
|
82
|
+
console.log(`front: notice ${notice.id} ${action}d by the admin`);
|
|
83
|
+
return j(action === 'create' ? 201 : 200, { ok: true, notice });
|
|
84
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// quiet.mjs — accounts that go quiet: what the front knows about an account
|
|
2
|
+
// nobody opens, and what its owner may say about it. The router in
|
|
3
|
+
// front-core.mjs asks closedState/accountState at the door and on every
|
|
4
|
+
// public read, stamps noteOpened from the relay, counts noteReceived after
|
|
5
|
+
// a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
|
|
6
|
+
|
|
7
|
+
// ---- accounts that go quiet -------------------------------------------------
|
|
8
|
+
//
|
|
9
|
+
// The gateway holds no mail: every delivery it takes is two writes into the
|
|
10
|
+
// owner's pod inbox, read only when their browser is open. An account nobody
|
|
11
|
+
// opens grows on its pod without limit, and its owner comes back to a drain
|
|
12
|
+
// of everything at once. So the front keeps two facts about each browser
|
|
13
|
+
// account — when its owner was last here, and how much content has arrived
|
|
14
|
+
// since — and acts on them.
|
|
15
|
+
//
|
|
16
|
+
// `openedAt` is written when the owner signs in (POST /api/open, always) and
|
|
17
|
+
// while they act through the relay (at most hourly). The count of content
|
|
18
|
+
// deliveries since then lives in its own record, keyed by handle AND
|
|
19
|
+
// openedAt: a fresh stamp starts a fresh count with nothing to reset, and a
|
|
20
|
+
// delivery's write can never overwrite a sign-in's. Two deliveries landing
|
|
21
|
+
// together may each read the same total and one increment is lost — the cap
|
|
22
|
+
// comes out slightly soft, never too strict.
|
|
23
|
+
//
|
|
24
|
+
// PAUSED: the count has reached the cap, or the owner said so. Content is
|
|
25
|
+
// accepted and discarded, control (follows, unfollows, moves, deletions,
|
|
26
|
+
// blocks) still lands. The owner's next sign-in ends a cap pause by itself; a
|
|
27
|
+
// pause they set lasts until they lift it.
|
|
28
|
+
//
|
|
29
|
+
// CLOSED: the owner said so, or nothing has opened the account for the close
|
|
30
|
+
// window. Found closed by time, it is written down so it stays closed. From
|
|
31
|
+
// then on the door, the actor and the handle answer 410 — never a failure
|
|
32
|
+
// code, which senders retry and count against this whole host, and never a
|
|
33
|
+
// success, which would keep them sending. Nothing on the pod is touched. An
|
|
34
|
+
// address that moved (see /api/move) keeps answering as moved; closing does
|
|
35
|
+
// not take that away.
|
|
36
|
+
//
|
|
37
|
+
// Only an account whose owner has signed in from a browser carries
|
|
38
|
+
// `openedAt`. A DeviceAgent behind this door never does: it drains its own
|
|
39
|
+
// inbox as it runs, so it is never counted, never paused and never closed by
|
|
40
|
+
// time. Accounts from before this was built are counted from their next
|
|
41
|
+
// sign-in.
|
|
42
|
+
export const DEFAULT_PAUSE_ITEMS = 5000;
|
|
43
|
+
export const DEFAULT_CLOSE_DAYS = 183; // six months
|
|
44
|
+
const OPEN_STAMP_EVERY_MS = 60 * 60_000; // the relay's stamp, at most hourly
|
|
45
|
+
const pauseItemsOf = (ctx) => (Number(ctx.pauseItems) > 0 ? Number(ctx.pauseItems) : DEFAULT_PAUSE_ITEMS);
|
|
46
|
+
const closeDaysOf = (ctx) => (Number(ctx.closeDays) > 0 ? Number(ctx.closeDays) : DEFAULT_CLOSE_DAYS);
|
|
47
|
+
const receivedKey = (key, rec) => `${key}/${rec.openedAt}`;
|
|
48
|
+
|
|
49
|
+
export async function receivedSince(ctx, key, rec) {
|
|
50
|
+
if (!ctx.readReceived || !rec.openedAt) return { items: 0, bytes: 0 };
|
|
51
|
+
const got = await ctx.readReceived(receivedKey(key, rec)).catch(() => null);
|
|
52
|
+
return { items: Number(got?.items) || 0, bytes: Number(got?.bytes) || 0 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One more content delivery reached the pod. Nothing to count against until
|
|
56
|
+
// the owner has been here once.
|
|
57
|
+
export async function noteReceived(ctx, key, rec, bytes) {
|
|
58
|
+
if (!ctx.readReceived || !ctx.writeReceived || !rec.openedAt) return;
|
|
59
|
+
const so = await receivedSince(ctx, key, rec);
|
|
60
|
+
await ctx.writeReceived(receivedKey(key, rec), { items: so.items + 1, bytes: so.bytes + (Number(bytes) || 0) })
|
|
61
|
+
.catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The owner is here. `always` is a sign-in; the relay stamps at most hourly,
|
|
65
|
+
// so a busy hour of posting is one write. The count that went with the old
|
|
66
|
+
// stamp is dropped: it is over, and a store should not keep one per hour.
|
|
67
|
+
export async function noteOpened(ctx, key, rec, { always = false } = {}) {
|
|
68
|
+
if (!ctx.putDirectory) return rec;
|
|
69
|
+
if (!always && rec.openedAt && Date.now() - Date.parse(rec.openedAt) < OPEN_STAMP_EVERY_MS) return rec;
|
|
70
|
+
const next = { ...rec, openedAt: new Date().toISOString() };
|
|
71
|
+
await ctx.putDirectory(key, next);
|
|
72
|
+
if (rec.openedAt && ctx.dropReceived) await ctx.dropReceived(receivedKey(key, rec)).catch(() => {});
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Closed, and why. Reads no count: this is asked on every public read.
|
|
77
|
+
export async function closedState(ctx, key, rec) {
|
|
78
|
+
if (rec.movedTo) return { closed: false };
|
|
79
|
+
if (rec.closedAt) return { closed: true, closedAt: rec.closedAt, closedBy: rec.closedBy || 'owner' };
|
|
80
|
+
if (rec.openedAt && Date.now() - Date.parse(rec.openedAt) > closeDaysOf(ctx) * 86400_000) {
|
|
81
|
+
const closedAt = new Date().toISOString();
|
|
82
|
+
if (ctx.putDirectory) await ctx.putDirectory(key, { ...rec, closedAt, closedBy: 'quiet' });
|
|
83
|
+
console.log(`front @${key}: closed — nothing opened it since ${rec.openedAt}`);
|
|
84
|
+
return { closed: true, closedAt, closedBy: 'quiet' };
|
|
85
|
+
}
|
|
86
|
+
return { closed: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The whole standing of an account, for the door and for its owner's page.
|
|
90
|
+
export async function accountState(ctx, key, rec) {
|
|
91
|
+
const c = await closedState(ctx, key, rec);
|
|
92
|
+
const received = c.closed ? { items: 0, bytes: 0 } : await receivedSince(ctx, key, rec);
|
|
93
|
+
const cap = pauseItemsOf(ctx);
|
|
94
|
+
const pausedBy = c.closed ? null : rec.pausedAt ? 'owner' : received.items >= cap ? 'quiet' : null;
|
|
95
|
+
return {
|
|
96
|
+
closed: c.closed, closedAt: c.closedAt || null, closedBy: c.closedBy || null,
|
|
97
|
+
paused: !!pausedBy, pausedBy, pausedAt: rec.pausedAt || null,
|
|
98
|
+
openedAt: rec.openedAt || null, received, pauseItems: cap, closeDays: closeDaysOf(ctx),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A 410 with a reason, the shape every closed or moved id answers with.
|
|
103
|
+
export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
|
|
104
|
+
status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
105
|
+
body: JSON.stringify({ error: why }) });
|
|
106
|
+
|
|
107
|
+
// The owner of a row, proved the way the relay proves them: a pod token whose
|
|
108
|
+
// WebID is the row's, or lives on the row's pod for a row from before WebIDs
|
|
109
|
+
// were recorded.
|
|
110
|
+
async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
|
|
111
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
112
|
+
if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
|
|
113
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
114
|
+
if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
|
|
115
|
+
return { webid };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The owner's three routes. `deps` are front-core's own JSON reply and
|
|
119
|
+
// token check, handed in so this module stays free of the router's privates.
|
|
120
|
+
// Returns a response, or null when the path is not one of these.
|
|
121
|
+
export async function routeQuietApi(request, pathname, ctx, deps) {
|
|
122
|
+
const { j } = deps;
|
|
123
|
+
if (request.method !== 'POST' || !['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
|
|
124
|
+
// The owner is here: their browser says so as it opens the account. The
|
|
125
|
+
// answer is the account's standing at this gateway, which the manage page
|
|
126
|
+
// shows. A closed address is told so and nothing is written.
|
|
127
|
+
if (pathname === '/api/open' && request.method === 'POST') {
|
|
128
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
129
|
+
let body;
|
|
130
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
131
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
132
|
+
let rec = await ctx.lookup(handle);
|
|
133
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
134
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
135
|
+
if (who.error) return who.error;
|
|
136
|
+
const before = await closedState(ctx, handle, rec);
|
|
137
|
+
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
|
|
138
|
+
rec = await noteOpened(ctx, handle, rec, { always: true });
|
|
139
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The owner pausing their own account, or lifting the pause they set.
|
|
143
|
+
if (pathname === '/api/pause' && request.method === 'POST') {
|
|
144
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
145
|
+
let body;
|
|
146
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
147
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
148
|
+
let rec = await ctx.lookup(handle);
|
|
149
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
150
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
151
|
+
if (who.error) return who.error;
|
|
152
|
+
const before = await closedState(ctx, handle, rec);
|
|
153
|
+
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt });
|
|
154
|
+
if (typeof body.paused !== 'boolean') return j(400, { error: 'paused must be true or false' });
|
|
155
|
+
if (body.paused && !rec.pausedAt) rec = { ...rec, pausedAt: new Date().toISOString() };
|
|
156
|
+
if (!body.paused && rec.pausedAt) { rec = { ...rec }; delete rec.pausedAt; }
|
|
157
|
+
// Lifting a pause is a sign-in's worth of "I am here": the count starts over.
|
|
158
|
+
if (!body.paused) rec = { ...rec, openedAt: new Date().toISOString() };
|
|
159
|
+
await ctx.putDirectory(handle, rec);
|
|
160
|
+
console.log(`front @${handle}: ${body.paused ? 'paused' : 'resumed'} by its owner`);
|
|
161
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The owner closing their address for good. Repeating it changes nothing.
|
|
165
|
+
if (pathname === '/api/close' && request.method === 'POST') {
|
|
166
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
167
|
+
let body;
|
|
168
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
169
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
170
|
+
let rec = await ctx.lookup(handle);
|
|
171
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
172
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
173
|
+
if (who.error) return who.error;
|
|
174
|
+
if (body.confirm !== true) return j(400, { error: 'closing is for good — send confirm: true' });
|
|
175
|
+
if (!rec.closedAt) {
|
|
176
|
+
rec = { ...rec, closedAt: new Date().toISOString(), closedBy: 'owner' };
|
|
177
|
+
await ctx.putDirectory(handle, rec);
|
|
178
|
+
console.log(`front @${handle}: closed by its owner`);
|
|
179
|
+
}
|
|
180
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/run-agent.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { apRoot, rootOf, writeJsonAtomic } from './lib/device/home.mjs';
|
|
|
33
33
|
import { ensureTrustedTls } from './lib/device/certs.mjs';
|
|
34
34
|
import { storageFor } from './lib/core/storage.mjs';
|
|
35
35
|
import { resolveKeys } from './lib/core/keys.mjs';
|
|
36
|
+
import { completeGatewayMove } from './lib/device/gateway-move.mjs';
|
|
36
37
|
import { RemotePod } from './lib/device/remote.mjs';
|
|
37
38
|
import { Deliverer } from './lib/core/deliver.mjs';
|
|
38
39
|
import { Publisher } from './lib/core/publisher/index.mjs';
|
|
@@ -547,6 +548,10 @@ export class Agent {
|
|
|
547
548
|
return; // renewal is already running, above
|
|
548
549
|
}
|
|
549
550
|
await this.intake.start();
|
|
551
|
+
// An address that moved here from another gateway, whose move was left
|
|
552
|
+
// pending (the old gateway could not be told): try again now that this
|
|
553
|
+
// agent acts and the actor is published.
|
|
554
|
+
completeGatewayMove(this).catch(e => this.log(`gateway move: ${e.message}`));
|
|
550
555
|
// Reused, not replaced: demote() stops this instance but does not clear it,
|
|
551
556
|
// so constructing a new one over the top orphaned the old chain beyond the
|
|
552
557
|
// reach of any later stop().
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -86,7 +86,7 @@ const shell = (text, c) => text
|
|
|
86
86
|
.replace('src="../../"', `src="${c.path}"`)
|
|
87
87
|
.replace('<script src="client.js"></script>',
|
|
88
88
|
c.login ? '<script src="/admin/client/client.js"></script>' : '')
|
|
89
|
-
.replace('
|
|
89
|
+
.replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
|
|
90
90
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
91
91
|
.replace('</body>', `${clientNews()}</body>`);
|
|
92
92
|
|
|
@@ -136,7 +136,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
136
136
|
// A sign-out beside "manage account", on every bar. It clears the FediPod
|
|
137
137
|
// session, the client's stored account, browser-stored connected keys, and
|
|
138
138
|
// the worker — the /?signout page (boot.js) does the teardown.
|
|
139
|
-
.replace(
|
|
139
|
+
.replace(/(<a id="bar-manage"[^>]*>manage<\/a>)/u, '$1\n <a id="bar-signout" href="/?signout" title=" Sign out of FediPod and clear this browser">sign out</a>')
|
|
140
140
|
.replace('</head>', '<style>#actor-pick{display:none!important}</style>\n</head>');
|
|
141
141
|
text = withUpdate(text);
|
|
142
142
|
if (/[/\\]client$/.test(from)) {
|
|
@@ -147,7 +147,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
147
147
|
} else if (/[/\\]admin$/.test(from)) {
|
|
148
148
|
// The record page carries the same client control as the shells: it is
|
|
149
149
|
// the page the owner is on when they want to change which client opens.
|
|
150
|
-
text = text.replace('
|
|
150
|
+
text = text.replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
|
|
151
151
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
152
152
|
.replace('</body>', `${clientNews()}</body>`);
|
|
153
153
|
// Hide controls the browser build does not carry: the manual drain (it
|
|
@@ -197,6 +197,9 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
197
197
|
'/api/roster /.netlify/functions/front 200',
|
|
198
198
|
'/api/revoke /.netlify/functions/front 200',
|
|
199
199
|
'/api/agent /.netlify/functions/front 200',
|
|
200
|
+
'/api/notices /.netlify/functions/front 200',
|
|
201
|
+
'/notices /.netlify/functions/front 200',
|
|
202
|
+
'/notices.js /.netlify/functions/front 200',
|
|
200
203
|
'/u/* /.netlify/functions/front 200',
|
|
201
204
|
'/.well-known/* /.netlify/functions/front 200',
|
|
202
205
|
'/@* /.netlify/functions/front 200',
|
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>
|