fedipod 1.30.0 → 1.36.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gateway.md +37 -0
- package/gui.md +2 -2
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +59 -66
- package/lib/gateway/gateway-core.mjs +20 -8
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +189 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/scripts/stage-site.mjs +6 -3
- package/web/admin/bar.css +44 -10
- package/web/admin/client/index.html +29 -2
- package/web/admin/gateway.js +19 -0
- package/web/admin/index.html +46 -3
- package/web/admin/notices-bar.js +90 -0
- package/web/admin/record.js +1 -0
- package/web/admin/setup/index.html +29 -2
- package/web/admin/upkeep.js +5 -1
- package/web/app/admin-facade.mjs +24 -0
- package/web/app/agent.mjs +59 -6
- package/web/app/boot.mjs +2 -0
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +39 -4
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +273 -38
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
|
@@ -21,6 +21,10 @@ 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';
|
|
26
|
+
import { withSecurityHeaders } from './headers.mjs';
|
|
27
|
+
import { podTokenVerifier } from './caches.mjs';
|
|
24
28
|
|
|
25
29
|
// The one WebFinger document, spelled out here rather than imported from
|
|
26
30
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -59,7 +63,7 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
59
63
|
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
60
64
|
// a token shown without the proof is one anyone who saw it could show.
|
|
61
65
|
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
62
|
-
const v = verifier ||
|
|
66
|
+
const v = verifier || await podTokenVerifier();
|
|
63
67
|
const url = request.url;
|
|
64
68
|
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
65
69
|
return webid || null;
|
|
@@ -205,7 +209,9 @@ async function relayOne(item, rec, fetchImpl) {
|
|
|
205
209
|
|
|
206
210
|
const j = (status, obj, ct = 'application/json') =>
|
|
207
211
|
({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
|
|
208
|
-
|
|
212
|
+
// Held at the edge like any other missing document: a bot's scan, a dead
|
|
213
|
+
// handle, a typo in a WebFinger query each cost one call, not one per asker.
|
|
214
|
+
const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain', ...publicFor(MISSING_EDGE_SECONDS) }, body: 'not found\n' });
|
|
209
215
|
|
|
210
216
|
// A user's public base on the front, a 1:1 mirror of their pod home.
|
|
211
217
|
// The directory key is the FULL fediverse address — handle@host — never the
|
|
@@ -241,7 +247,9 @@ function parseUserPath(pathname) {
|
|
|
241
247
|
const BROWSER_MAX_AGE = 60;
|
|
242
248
|
const publicFor = (seconds, stale = seconds * 4) => ({
|
|
243
249
|
'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
|
|
244
|
-
|
|
250
|
+
// `durable`: one copy for every region, and one that a deploy does not
|
|
251
|
+
// empty. Without it each region held its own and every deploy started cold.
|
|
252
|
+
'netlify-cdn-cache-control': `public, durable, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
|
|
245
253
|
});
|
|
246
254
|
// How long the edge holds a public document, and a handle's WebFinger
|
|
247
255
|
// answer. Every server that has heard of an account asks for its actor and
|
|
@@ -250,6 +258,15 @@ const publicFor = (seconds, stale = seconds * 4) => ({
|
|
|
250
258
|
// can afford.
|
|
251
259
|
const PUBLIC_EDGE_SECONDS = 600;
|
|
252
260
|
const WEBFINGER_EDGE_SECONDS = 3600;
|
|
261
|
+
// A closed or moved address stays gone; a picture's address stays where it is.
|
|
262
|
+
const GONE_EDGE_SECONDS = 3600;
|
|
263
|
+
const MEDIA_EDGE_SECONDS = 86400;
|
|
264
|
+
// A public document the pod would not give (missing, or not public) is asked
|
|
265
|
+
// for again and again by every server that shows the account — a pinned-posts
|
|
266
|
+
// collection an old account never wrote, a forum count nobody published. Held
|
|
267
|
+
// briefly, so the edge absorbs the asking; briefly, so a document published a
|
|
268
|
+
// moment later is not "missing" for long.
|
|
269
|
+
const MISSING_EDGE_SECONDS = 120;
|
|
253
270
|
|
|
254
271
|
// Where a pod owner opts their identity in. An operator may name it, because
|
|
255
272
|
// the path it takes is one their pod server can no longer serve; the dot says
|
|
@@ -276,7 +293,7 @@ const sameOriginRequest = (request, origin) => {
|
|
|
276
293
|
|
|
277
294
|
// Top-level paths a handle may not take, so a name never shadows a route.
|
|
278
295
|
const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
|
|
279
|
-
'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
|
|
296
|
+
'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install', 'notices']);
|
|
280
297
|
|
|
281
298
|
// Why a handle is unusable, or null when it is fine. Lowercase letters, digits
|
|
282
299
|
// and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
|
|
@@ -332,6 +349,7 @@ function identFor(rec, policy = null) {
|
|
|
332
349
|
// listDirectory() -> { handle: record } every row, for the admin roster
|
|
333
350
|
// removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
|
|
334
351
|
// podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
|
|
352
|
+
// readReceived / writeReceived / dropReceived, pauseItems, closeDays — see quiet.mjs
|
|
335
353
|
// podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
|
|
336
354
|
// is fine). `podHome` is the row's own pod, for
|
|
337
355
|
// an adapter that reads a store directly and so
|
|
@@ -341,51 +359,6 @@ export async function routeFront(request, ctx) {
|
|
|
341
359
|
return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
|
|
342
360
|
}
|
|
343
361
|
|
|
344
|
-
// Every response this file makes, hardened in one place rather than in each of
|
|
345
|
-
// the dozen shapes below.
|
|
346
|
-
//
|
|
347
|
-
// `nosniff` matters most: the front serves user-supplied JSON straight from
|
|
348
|
-
// somebody's pod (the proxied actor and object documents), and without it a
|
|
349
|
-
// browser is free to decide for itself that a document is HTML and run what is
|
|
350
|
-
// inside it. The rest is the same posture the app already has — nothing may be
|
|
351
|
-
// framed, no base tag may be rewritten, no plugin content.
|
|
352
|
-
//
|
|
353
|
-
// A content-security-policy goes on the HTML only: it would mean nothing on a
|
|
354
|
-
// JSON document, and `frame-ancestors` has to be a header rather than a meta
|
|
355
|
-
// tag anyway.
|
|
356
|
-
//
|
|
357
|
-
// `script-src 'self'` is the one that matters, and it is only possible because
|
|
358
|
-
// none of these pages carries inline script any more — each has its own file and
|
|
359
|
-
// its own route above. A policy cannot tell an inline block the author wrote
|
|
360
|
-
// from one an attacker injected, so as long as any inline script has to run,
|
|
361
|
-
// every inline script may.
|
|
362
|
-
//
|
|
363
|
-
// `connect-src` allows https: because the pages sign in against the user's own
|
|
364
|
-
// pod, which is a different origin by definition and not one we can name here.
|
|
365
|
-
function withSecurityHeaders(headers = {}, body = null) {
|
|
366
|
-
const ct = String(headers['content-type'] || '');
|
|
367
|
-
const isHtml = ct.startsWith('text/html');
|
|
368
|
-
return {
|
|
369
|
-
...headers,
|
|
370
|
-
'x-content-type-options': 'nosniff',
|
|
371
|
-
'referrer-policy': 'same-origin',
|
|
372
|
-
'x-frame-options': 'SAMEORIGIN',
|
|
373
|
-
...(isHtml && body ? {
|
|
374
|
-
'content-security-policy': [
|
|
375
|
-
"default-src 'self'",
|
|
376
|
-
"script-src 'self'", // no inline script: see above
|
|
377
|
-
"style-src 'self' 'unsafe-inline'",
|
|
378
|
-
"img-src 'self' https: data:",
|
|
379
|
-
"connect-src 'self' https:", // sign-in goes to the user's own pod
|
|
380
|
-
"object-src 'none'", // no plugin content, ever
|
|
381
|
-
"base-uri 'none'", // no rewriting where relative URLs resolve
|
|
382
|
-
"frame-ancestors 'self'", // nobody else may frame these pages
|
|
383
|
-
"form-action 'self'", // a form here submits here
|
|
384
|
-
].join('; '),
|
|
385
|
-
} : {}),
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
362
|
async function route(request, ctx) {
|
|
390
363
|
const url = new URL(request.url);
|
|
391
364
|
const { pathname } = url;
|
|
@@ -409,14 +382,13 @@ async function route(request, ctx) {
|
|
|
409
382
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
|
|
410
383
|
}
|
|
411
384
|
|
|
412
|
-
// The roster page
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
385
|
+
// The roster page (/roster, off /admin, which is the owner's own record
|
|
386
|
+
// page on every agent) is served with the notices page, in notices.mjs.
|
|
387
|
+
|
|
388
|
+
// Notices from the operator to every account here, and the page they are
|
|
389
|
+
// written on (notices.mjs).
|
|
390
|
+
const notices = await routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound });
|
|
391
|
+
if (notices) return notices;
|
|
420
392
|
|
|
421
393
|
// The roster: every directory row, secrets stripped, for the host's own
|
|
422
394
|
// eyes. The reader proves themself the way attach proves a pod — a
|
|
@@ -432,6 +404,7 @@ async function route(request, ctx) {
|
|
|
432
404
|
handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
|
|
433
405
|
podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
|
|
434
406
|
address: r.address || `@${r.handle}@${ctx.host}`,
|
|
407
|
+
openedAt: r.openedAt || null, pausedAt: r.pausedAt || null, closedAt: r.closedAt || null,
|
|
435
408
|
}))
|
|
436
409
|
.sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
|
|
437
410
|
return j(200, { host: ctx.host, accounts });
|
|
@@ -639,6 +612,10 @@ async function route(request, ctx) {
|
|
|
639
612
|
return withApiCors(j(200, { ok: true, handle, movedTo, movedAt }));
|
|
640
613
|
}
|
|
641
614
|
|
|
615
|
+
// The owner's say over an account that goes quiet (quiet.mjs).
|
|
616
|
+
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
617
|
+
if (quiet) return withApiCors(quiet);
|
|
618
|
+
|
|
642
619
|
// The relay: the front sends requests a browser has already signed. A page
|
|
643
620
|
// may not set the Date or Host header, and both are inside an HTTP
|
|
644
621
|
// signature, so a browser-run agent signs and hands the request here; the
|
|
@@ -659,6 +636,8 @@ async function route(request, ctx) {
|
|
|
659
636
|
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
637
|
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
661
638
|
if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
|
|
639
|
+
// Acting through the relay is being here. Hourly at most; see noteOpened.
|
|
640
|
+
await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
|
|
662
641
|
if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
|
|
663
642
|
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
664
643
|
// One line per relayed request, so a lookup that fails on the far side is
|
|
@@ -767,7 +746,7 @@ async function route(request, ctx) {
|
|
|
767
746
|
//
|
|
768
747
|
// The name is matched against a literal list, so nothing about the request
|
|
769
748
|
// chooses a file.
|
|
770
|
-
const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
|
|
749
|
+
const mPageScript = /^\/(new-account|run|admin|notices)\.js$/u.exec(pathname);
|
|
771
750
|
if (mPageScript) {
|
|
772
751
|
const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
|
|
773
752
|
if (!body) return notFound();
|
|
@@ -797,6 +776,9 @@ async function route(request, ctx) {
|
|
|
797
776
|
if (!m || m[2] !== ctx.host) return notFound();
|
|
798
777
|
const rec = await ctx.lookup(m[1]);
|
|
799
778
|
if (!rec) return notFound();
|
|
779
|
+
// A closed address is gone, and says so rather than pretending never to
|
|
780
|
+
// have existed: the name stays taken.
|
|
781
|
+
if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*', ...publicFor(GONE_EDGE_SECONDS) });
|
|
800
782
|
// A fronted identity's documents live on its pod; the pod's own actor id
|
|
801
783
|
// is the alias, so a client signing in by the fronted address can find
|
|
802
784
|
// the pod (and its login) without a lookup only the host could answer.
|
|
@@ -837,19 +819,25 @@ async function route(request, ctx) {
|
|
|
837
819
|
// An account that moved to another gateway (see /api/move): where its ids
|
|
838
820
|
// live now. The actor below answers as a stub; everything else redirects.
|
|
839
821
|
const movedBase = rec.movedTo ? rec.movedTo.replace(/ap\/actor$/u, '') : null;
|
|
840
|
-
|
|
841
|
-
|
|
822
|
+
// A closed address (see "accounts that go quiet" above): every id under it
|
|
823
|
+
// is gone. Asked after moved, which a close does not undo.
|
|
824
|
+
const closed = movedBase ? false : (await closedState(ctx, up.handle, rec)).closed;
|
|
825
|
+
const gone = (headers = {}, why = `this account has moved to ${rec.movedTo}`) => closedAnswer(headers, why);
|
|
826
|
+
const CLOSED = 'this address is closed';
|
|
842
827
|
|
|
843
828
|
// Inbox: verify at the door, forward to the user's pod inbox. This is the
|
|
844
829
|
// gateway, per user.
|
|
845
830
|
if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
|
|
846
831
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
847
832
|
if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
|
|
833
|
+
if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
|
|
848
834
|
const policy = await policyFor(rec, ctx.fetchImpl || fetch);
|
|
849
|
-
const
|
|
850
|
-
|
|
835
|
+
const standing = await accountState(ctx, up.handle, rec);
|
|
836
|
+
const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
|
|
837
|
+
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
851
838
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
852
839
|
console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
|
|
840
|
+
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
853
841
|
return { status, headers: {}, body: '' };
|
|
854
842
|
}
|
|
855
843
|
|
|
@@ -876,6 +864,7 @@ async function route(request, ctx) {
|
|
|
876
864
|
'accept-post': 'application/ld+json, application/activity+json' }, body: null };
|
|
877
865
|
}
|
|
878
866
|
if (movedBase && request.method === 'POST') return gone(cors);
|
|
867
|
+
if (closed && request.method === 'POST') return gone(cors, CLOSED);
|
|
879
868
|
if (movedBase && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
880
869
|
return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
881
870
|
}
|
|
@@ -890,8 +879,8 @@ async function route(request, ctx) {
|
|
|
890
879
|
const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
|
|
891
880
|
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
|
|
892
881
|
console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
|
|
893
|
-
if (status !==
|
|
894
|
-
return json(
|
|
882
|
+
if (status !== 201) return json(status, { error: reason });
|
|
883
|
+
return json(201, { accepted: true, ...(location ? { object: location } : {}),
|
|
895
884
|
note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
896
885
|
}
|
|
897
886
|
if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
@@ -916,13 +905,14 @@ async function route(request, ctx) {
|
|
|
916
905
|
if (movedBase && up.rest !== 'ap/actor') {
|
|
917
906
|
return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
918
907
|
}
|
|
908
|
+
if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
|
|
919
909
|
const podTarget = rec.podHome + up.rest;
|
|
920
910
|
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
921
911
|
// the id rewrite below turns media links onto the front like every other
|
|
922
912
|
// pod url in a document. Answer those by pointing at the pod: bytes are not
|
|
923
913
|
// a document to cap and relabel, and remotes follow a redirect for a picture.
|
|
924
914
|
if (up.rest.startsWith('ap/media/')) {
|
|
925
|
-
return { status: 302, headers: { location: podTarget,
|
|
915
|
+
return { status: 302, headers: { location: podTarget, ...publicFor(MEDIA_EDGE_SECONDS) }, body: '' };
|
|
926
916
|
}
|
|
927
917
|
// The pod this read belongs to travels with it: an adapter reading a store
|
|
928
918
|
// directly (the CSS server component) has no access control of its own and
|
|
@@ -938,7 +928,10 @@ async function route(request, ctx) {
|
|
|
938
928
|
const got = await podRoot.readPublicDocument(
|
|
939
929
|
ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
|
|
940
930
|
podTarget, { podHome: rec.podHome, accept });
|
|
941
|
-
if (got.text === null)
|
|
931
|
+
if (got.text === null) {
|
|
932
|
+
const hold = [401, 403, 404, 410].includes(got.status) ? publicFor(MISSING_EDGE_SECONDS) : {};
|
|
933
|
+
return { status: got.status, headers: { ...open, ...hold }, body: '' };
|
|
934
|
+
}
|
|
942
935
|
let text = got.text;
|
|
943
936
|
text = swap(text, rec.podHome, base);
|
|
944
937
|
// The moved stub: the pod's actor now carries the NEW gateway's ids, and a
|
|
@@ -15,13 +15,16 @@
|
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
|
|
17
17
|
import * as inbox from '../pod/inbox.mjs';
|
|
18
|
+
import { senderKeys } from './caches.mjs';
|
|
18
19
|
|
|
19
20
|
const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
|
|
20
21
|
|
|
21
22
|
// 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
|
-
|
|
23
|
+
// Follows from strangers. Only CONTENT is subject to the concerns-us drop,
|
|
24
|
+
// and to a pause: an account nobody is reading still takes its follows.
|
|
25
|
+
export const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
|
|
24
26
|
'Add', 'Remove', 'Block']);
|
|
27
|
+
export const isControl = (type) => CONTROL.has(type);
|
|
25
28
|
|
|
26
29
|
const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
|
|
27
30
|
const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
@@ -64,8 +67,13 @@ function concernsUsAtEdge(activity, ident) {
|
|
|
64
67
|
// resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
|
|
65
68
|
// following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
|
|
66
69
|
// contentType) → boolean` appends to the pod with the gateway's credential.
|
|
67
|
-
//
|
|
68
|
-
|
|
70
|
+
// `paused` says nobody is reading this inbox: content is accepted and
|
|
71
|
+
// discarded (the same quiet 202 a blocked sender gets, so nothing retries
|
|
72
|
+
// and nothing counts a failure against this host), control still lands.
|
|
73
|
+
// Returns { status, reason } — the adapter turns it into an HTTP response —
|
|
74
|
+
// and, for a delivery that reached the pod, `content` (whether it was
|
|
75
|
+
// content rather than control) and `bytes`, so the caller can keep count.
|
|
76
|
+
export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
|
|
69
77
|
// Read the body once from a clone; the original, unconsumed, goes to the
|
|
70
78
|
// verifier (which needs the body for the Digest check).
|
|
71
79
|
let raw;
|
|
@@ -80,12 +88,16 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
80
88
|
// Edge drops — silent 202 so a rejected sender does not retry a delivery we
|
|
81
89
|
// will never accept. None of these becomes a pod write.
|
|
82
90
|
if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
|
|
83
|
-
|
|
91
|
+
const content = !CONTROL.has(activity.type);
|
|
92
|
+
if (content && paused) return { status: 202, reason: 'paused' };
|
|
93
|
+
if (content && !concernsUsAtEdge(activity, ident)) {
|
|
84
94
|
return { status: 202, reason: 'does not concern us' };
|
|
85
95
|
}
|
|
86
96
|
|
|
97
|
+
// The sender's key, kept between deliveries: a server pushing a hundred
|
|
98
|
+
// items is asked for its key once (caches.mjs).
|
|
87
99
|
const v = await verifyHttpSignature(request, {
|
|
88
|
-
documentLoader: makeSafeLoader({ fetchImpl }),
|
|
100
|
+
documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
|
|
89
101
|
});
|
|
90
102
|
// A present-but-invalid signature is a forgery — dropped here, so it never
|
|
91
103
|
// reaches the pod (today it would, drain, and die unapplied). An absent or
|
|
@@ -107,7 +119,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
107
119
|
// holding any state.
|
|
108
120
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
109
121
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
110
|
-
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
|
|
122
|
+
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw) };
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -147,7 +159,7 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
|
|
|
147
159
|
const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
|
|
148
160
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
149
161
|
await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
150
|
-
return { status:
|
|
162
|
+
return { status: 201, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// headers.mjs — the hardening every front response carries, in one place.
|
|
2
|
+
// Split out of front-core.mjs, which is at its size gate; nothing here knows
|
|
3
|
+
// about routes.
|
|
4
|
+
|
|
5
|
+
// Every response this file makes, hardened in one place rather than in each of
|
|
6
|
+
// the dozen shapes below.
|
|
7
|
+
//
|
|
8
|
+
// `nosniff` matters most: the front serves user-supplied JSON straight from
|
|
9
|
+
// somebody's pod (the proxied actor and object documents), and without it a
|
|
10
|
+
// browser is free to decide for itself that a document is HTML and run what is
|
|
11
|
+
// inside it. The rest is the same posture the app already has — nothing may be
|
|
12
|
+
// framed, no base tag may be rewritten, no plugin content.
|
|
13
|
+
//
|
|
14
|
+
// A content-security-policy goes on the HTML only: it would mean nothing on a
|
|
15
|
+
// JSON document, and `frame-ancestors` has to be a header rather than a meta
|
|
16
|
+
// tag anyway.
|
|
17
|
+
//
|
|
18
|
+
// `script-src 'self'` is the one that matters, and it is only possible because
|
|
19
|
+
// none of these pages carries inline script any more — each has its own file and
|
|
20
|
+
// its own route above. A policy cannot tell an inline block the author wrote
|
|
21
|
+
// from one an attacker injected, so as long as any inline script has to run,
|
|
22
|
+
// every inline script may.
|
|
23
|
+
//
|
|
24
|
+
// `connect-src` allows https: because the pages sign in against the user's own
|
|
25
|
+
// pod, which is a different origin by definition and not one we can name here.
|
|
26
|
+
export function withSecurityHeaders(headers = {}, body = null) {
|
|
27
|
+
const ct = String(headers['content-type'] || '');
|
|
28
|
+
const isHtml = ct.startsWith('text/html');
|
|
29
|
+
return {
|
|
30
|
+
...headers,
|
|
31
|
+
'x-content-type-options': 'nosniff',
|
|
32
|
+
'referrer-policy': 'same-origin',
|
|
33
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
34
|
+
...(isHtml && body ? {
|
|
35
|
+
'content-security-policy': [
|
|
36
|
+
"default-src 'self'",
|
|
37
|
+
"script-src 'self'", // no inline script: see above
|
|
38
|
+
"style-src 'self' 'unsafe-inline'",
|
|
39
|
+
"img-src 'self' https: data:",
|
|
40
|
+
"connect-src 'self' https:", // sign-in goes to the user's own pod
|
|
41
|
+
"object-src 'none'", // no plugin content, ever
|
|
42
|
+
"base-uri 'none'", // no rewriting where relative URLs resolve
|
|
43
|
+
"frame-ancestors 'self'", // nobody else may frame these pages
|
|
44
|
+
"form-action 'self'", // a form here submits here
|
|
45
|
+
].join('; '),
|
|
46
|
+
} : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -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
|
+
}
|