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/README.md
CHANGED
|
@@ -72,7 +72,7 @@ client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
|
72
72
|
address in your actor document, which your WebID profile also names, signed in
|
|
73
73
|
at your pod. The post goes out the next time you open fedipod.net.
|
|
74
74
|
|
|
75
|
-
**The manage page.** `manage
|
|
75
|
+
**The manage page.** `manage`, in the bar's account group, opens it: your profile,
|
|
76
76
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
77
77
|
another server, retiring, and clearing a backlog. It is the same interface
|
|
78
78
|
the DeviceAgent has, described in [the admin interface](gui.md).
|
package/gateway.md
CHANGED
|
@@ -153,6 +153,43 @@ on the setup page). Setup reads the account on the pod, keeps its state and
|
|
|
153
153
|
key there, attaches at the new gateway, and completes the move when the
|
|
154
154
|
agent first acts. See [the DeviceAgent](device-agent.md).
|
|
155
155
|
|
|
156
|
+
## Accounts that go quiet
|
|
157
|
+
|
|
158
|
+
A gateway holds no mail. Every delivery it accepts is written into your pod
|
|
159
|
+
inbox, and your agent reads it from there. A BrowserAgent reads only while
|
|
160
|
+
its page is open, so an account nobody opens grows on its pod without limit
|
|
161
|
+
and comes back to a drain of everything at once. fedipod.net keeps two facts
|
|
162
|
+
about each browser account — when its owner last signed in or posted, and
|
|
163
|
+
how much content has arrived since — and acts on them.
|
|
164
|
+
|
|
165
|
+
**Paused.** After about 5,000 posts, replies, likes, boosts and edits since
|
|
166
|
+
you were last here, or when you say so on the manage page, the door accepts
|
|
167
|
+
content and discards it. Follows, unfollows, account moves, deletions and
|
|
168
|
+
blocks still reach your pod. Signing in ends an automatic pause by itself; a
|
|
169
|
+
pause you set lasts until you lift it.
|
|
170
|
+
|
|
171
|
+
**Closed.** After six months without a sign-in, or when you say so on the
|
|
172
|
+
manage page, the address is closed for good: its handle, its actor and its
|
|
173
|
+
door answer 410 Gone, other servers drop the account the next time they look,
|
|
174
|
+
and nobody can take the name. Nothing on your pod is touched. An address that
|
|
175
|
+
had already moved to another gateway keeps answering as moved.
|
|
176
|
+
|
|
177
|
+
Only accounts opened from a browser are counted. A DeviceAgent behind the
|
|
178
|
+
gateway drains its own inbox as it runs, and is never paused or closed by
|
|
179
|
+
time. Accounts from before this was built are counted from their next
|
|
180
|
+
sign-in. A gateway operator sets the cap and the window with
|
|
181
|
+
`FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
|
|
182
|
+
|
|
183
|
+
## Notices
|
|
184
|
+
|
|
185
|
+
Whoever runs the gateway can write notices to everyone with an account
|
|
186
|
+
there. A bell at the right end of the bar, on the record page and in the
|
|
187
|
+
client, shows how many this browser has not opened yet; the list gives the
|
|
188
|
+
titles and each one opens on its own. The operator writes, changes and
|
|
189
|
+
removes them at `/notices`, signed in as the admin the way the roster is.
|
|
190
|
+
A notice is a title and plain text: a blank line starts a paragraph, and a
|
|
191
|
+
web address becomes a link.
|
|
192
|
+
|
|
156
193
|
## What the gateway can see
|
|
157
194
|
|
|
158
195
|
It reads only public data to decide what concerns you: your published
|
package/gui.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# The Admin interface
|
|
2
2
|
|
|
3
|
-
In the browser version at fedipod.net, `manage
|
|
3
|
+
In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
|
|
4
4
|
same page for your account. The rest of this page describes it as the
|
|
5
5
|
DeviceAgent serves it; the controls are the same, minus the manual inbox
|
|
6
6
|
drain and the local log, which a browser does not have.
|
|
7
7
|
|
|
8
|
-
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account
|
|
8
|
+
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
|
|
9
9
|
Picking an actor marked "(stopped)" starts its agent, then opens its page.
|
|
10
10
|
|
|
11
11
|
The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
|
|
@@ -21,6 +21,8 @@ import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
|
|
|
21
21
|
import * as podRoot from '../pod/root.mjs';
|
|
22
22
|
import * as podPolicy from '../pod/policy.mjs';
|
|
23
23
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
24
|
+
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
|
|
25
|
+
import { routeNoticesApi } from './notices.mjs';
|
|
24
26
|
|
|
25
27
|
// The one WebFinger document, spelled out here rather than imported from
|
|
26
28
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -276,7 +278,7 @@ const sameOriginRequest = (request, origin) => {
|
|
|
276
278
|
|
|
277
279
|
// Top-level paths a handle may not take, so a name never shadows a route.
|
|
278
280
|
const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
|
|
279
|
-
'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
|
|
281
|
+
'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install', 'notices']);
|
|
280
282
|
|
|
281
283
|
// Why a handle is unusable, or null when it is fine. Lowercase letters, digits
|
|
282
284
|
// and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
|
|
@@ -332,6 +334,7 @@ function identFor(rec, policy = null) {
|
|
|
332
334
|
// listDirectory() -> { handle: record } every row, for the admin roster
|
|
333
335
|
// removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
|
|
334
336
|
// podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
|
|
337
|
+
// readReceived / writeReceived / dropReceived, pauseItems, closeDays — see quiet.mjs
|
|
335
338
|
// podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
|
|
336
339
|
// is fine). `podHome` is the row's own pod, for
|
|
337
340
|
// an adapter that reads a store directly and so
|
|
@@ -409,14 +412,13 @@ async function route(request, ctx) {
|
|
|
409
412
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
|
|
410
413
|
}
|
|
411
414
|
|
|
412
|
-
// The roster page
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
415
|
+
// The roster page (/roster, off /admin, which is the owner's own record
|
|
416
|
+
// page on every agent) is served with the notices page, in notices.mjs.
|
|
417
|
+
|
|
418
|
+
// Notices from the operator to every account here, and the page they are
|
|
419
|
+
// written on (notices.mjs).
|
|
420
|
+
const notices = await routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound });
|
|
421
|
+
if (notices) return notices;
|
|
420
422
|
|
|
421
423
|
// The roster: every directory row, secrets stripped, for the host's own
|
|
422
424
|
// eyes. The reader proves themself the way attach proves a pod — a
|
|
@@ -432,6 +434,7 @@ async function route(request, ctx) {
|
|
|
432
434
|
handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
|
|
433
435
|
podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
|
|
434
436
|
address: r.address || `@${r.handle}@${ctx.host}`,
|
|
437
|
+
openedAt: r.openedAt || null, pausedAt: r.pausedAt || null, closedAt: r.closedAt || null,
|
|
435
438
|
}))
|
|
436
439
|
.sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
|
|
437
440
|
return j(200, { host: ctx.host, accounts });
|
|
@@ -639,6 +642,10 @@ async function route(request, ctx) {
|
|
|
639
642
|
return withApiCors(j(200, { ok: true, handle, movedTo, movedAt }));
|
|
640
643
|
}
|
|
641
644
|
|
|
645
|
+
// The owner's say over an account that goes quiet (quiet.mjs).
|
|
646
|
+
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
|
|
647
|
+
if (quiet) return quiet;
|
|
648
|
+
|
|
642
649
|
// The relay: the front sends requests a browser has already signed. A page
|
|
643
650
|
// may not set the Date or Host header, and both are inside an HTTP
|
|
644
651
|
// signature, so a browser-run agent signs and hands the request here; the
|
|
@@ -659,6 +666,8 @@ async function route(request, ctx) {
|
|
|
659
666
|
if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
|
|
660
667
|
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
661
668
|
if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
|
|
669
|
+
// Acting through the relay is being here. Hourly at most; see noteOpened.
|
|
670
|
+
await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
|
|
662
671
|
if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
|
|
663
672
|
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
664
673
|
// One line per relayed request, so a lookup that fails on the far side is
|
|
@@ -767,7 +776,7 @@ async function route(request, ctx) {
|
|
|
767
776
|
//
|
|
768
777
|
// The name is matched against a literal list, so nothing about the request
|
|
769
778
|
// chooses a file.
|
|
770
|
-
const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
|
|
779
|
+
const mPageScript = /^\/(new-account|run|admin|notices)\.js$/u.exec(pathname);
|
|
771
780
|
if (mPageScript) {
|
|
772
781
|
const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
|
|
773
782
|
if (!body) return notFound();
|
|
@@ -797,6 +806,9 @@ async function route(request, ctx) {
|
|
|
797
806
|
if (!m || m[2] !== ctx.host) return notFound();
|
|
798
807
|
const rec = await ctx.lookup(m[1]);
|
|
799
808
|
if (!rec) return notFound();
|
|
809
|
+
// A closed address is gone, and says so rather than pretending never to
|
|
810
|
+
// have existed: the name stays taken.
|
|
811
|
+
if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
|
|
800
812
|
// A fronted identity's documents live on its pod; the pod's own actor id
|
|
801
813
|
// is the alias, so a client signing in by the fronted address can find
|
|
802
814
|
// the pod (and its login) without a lookup only the host could answer.
|
|
@@ -837,19 +849,25 @@ async function route(request, ctx) {
|
|
|
837
849
|
// An account that moved to another gateway (see /api/move): where its ids
|
|
838
850
|
// live now. The actor below answers as a stub; everything else redirects.
|
|
839
851
|
const movedBase = rec.movedTo ? rec.movedTo.replace(/ap\/actor$/u, '') : null;
|
|
840
|
-
|
|
841
|
-
|
|
852
|
+
// A closed address (see "accounts that go quiet" above): every id under it
|
|
853
|
+
// is gone. Asked after moved, which a close does not undo.
|
|
854
|
+
const closed = movedBase ? false : (await closedState(ctx, up.handle, rec)).closed;
|
|
855
|
+
const gone = (headers = {}, why = `this account has moved to ${rec.movedTo}`) => closedAnswer(headers, why);
|
|
856
|
+
const CLOSED = 'this address is closed';
|
|
842
857
|
|
|
843
858
|
// Inbox: verify at the door, forward to the user's pod inbox. This is the
|
|
844
859
|
// gateway, per user.
|
|
845
860
|
if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
|
|
846
861
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
847
862
|
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
|
|
863
|
+
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
|
|
848
864
|
const policy = await policyFor(rec, ctx.fetchImpl || fetch);
|
|
849
|
-
const
|
|
850
|
-
|
|
865
|
+
const standing = await accountState(ctx, up.handle, rec);
|
|
866
|
+
const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
|
|
867
|
+
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
851
868
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
852
869
|
console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
|
|
870
|
+
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
853
871
|
return { status, headers: {}, body: '' };
|
|
854
872
|
}
|
|
855
873
|
|
|
@@ -876,6 +894,7 @@ async function route(request, ctx) {
|
|
|
876
894
|
'accept-post': 'application/ld+json, application/activity+json' }, body: null };
|
|
877
895
|
}
|
|
878
896
|
if (movedBase && request.method === 'POST') return gone(cors);
|
|
897
|
+
if (closed && request.method === 'POST') return gone(cors, CLOSED);
|
|
879
898
|
if (movedBase && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
880
899
|
return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
881
900
|
}
|
|
@@ -916,6 +935,7 @@ async function route(request, ctx) {
|
|
|
916
935
|
if (movedBase && up.rest !== 'ap/actor') {
|
|
917
936
|
return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
918
937
|
}
|
|
938
|
+
if (closed) return gone(open, CLOSED);
|
|
919
939
|
const podTarget = rec.podHome + up.rest;
|
|
920
940
|
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
921
941
|
// the id rewrite below turns media links onto the front like every other
|
|
@@ -19,9 +19,11 @@ import * as inbox from '../pod/inbox.mjs';
|
|
|
19
19
|
const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
|
|
20
20
|
|
|
21
21
|
// Control activities are the message itself and must always pass — you want
|
|
22
|
-
// Follows from strangers. Only CONTENT is subject to the concerns-us drop
|
|
23
|
-
|
|
22
|
+
// Follows from strangers. Only CONTENT is subject to the concerns-us drop,
|
|
23
|
+
// and to a pause: an account nobody is reading still takes its follows.
|
|
24
|
+
export const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
|
|
24
25
|
'Add', 'Remove', 'Block']);
|
|
26
|
+
export const isControl = (type) => CONTROL.has(type);
|
|
25
27
|
|
|
26
28
|
const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
|
|
27
29
|
const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
@@ -64,8 +66,13 @@ function concernsUsAtEdge(activity, ident) {
|
|
|
64
66
|
// resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
|
|
65
67
|
// following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
|
|
66
68
|
// contentType) → boolean` appends to the pod with the gateway's credential.
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
+
// `paused` says nobody is reading this inbox: content is accepted and
|
|
70
|
+
// discarded (the same quiet 202 a blocked sender gets, so nothing retries
|
|
71
|
+
// and nothing counts a failure against this host), control still lands.
|
|
72
|
+
// Returns { status, reason } — the adapter turns it into an HTTP response —
|
|
73
|
+
// and, for a delivery that reached the pod, `content` (whether it was
|
|
74
|
+
// content rather than control) and `bytes`, so the caller can keep count.
|
|
75
|
+
export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
|
|
69
76
|
// Read the body once from a clone; the original, unconsumed, goes to the
|
|
70
77
|
// verifier (which needs the body for the Digest check).
|
|
71
78
|
let raw;
|
|
@@ -80,7 +87,9 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
80
87
|
// Edge drops — silent 202 so a rejected sender does not retry a delivery we
|
|
81
88
|
// will never accept. None of these becomes a pod write.
|
|
82
89
|
if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
|
|
83
|
-
|
|
90
|
+
const content = !CONTROL.has(activity.type);
|
|
91
|
+
if (content && paused) return { status: 202, reason: 'paused' };
|
|
92
|
+
if (content && !concernsUsAtEdge(activity, ident)) {
|
|
84
93
|
return { status: 202, reason: 'does not concern us' };
|
|
85
94
|
}
|
|
86
95
|
|
|
@@ -107,7 +116,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
107
116
|
// holding any state.
|
|
108
117
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
109
118
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
110
|
-
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
|
|
119
|
+
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw) };
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -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/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',
|