fedipod 1.50.2 → 1.56.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 +2 -1
- package/gateway.md +25 -0
- package/gui.md +11 -3
- package/lib/client/masto/timelines.mjs +5 -0
- package/lib/connections/acctfeed.mjs +4 -1
- package/lib/connections/tagfeed.mjs +4 -1
- package/lib/core/intake/index.mjs +35 -2
- package/lib/core/intake/verify.mjs +3 -1
- package/lib/core/pod-only.mjs +4 -2
- package/lib/core/publisher/index.mjs +19 -5
- package/lib/gateway/account-agent.mjs +11 -5
- package/lib/gateway/check-letter.mjs +123 -0
- package/lib/gateway/connected-feeds.mjs +116 -0
- package/lib/gateway/copy.mjs +19 -0
- package/lib/gateway/forum-pages.mjs +59 -0
- package/lib/gateway/front-core.mjs +7 -4
- package/lib/gateway/held-mail.mjs +25 -1
- package/lib/gateway/keeper.mjs +2 -1
- package/lib/gateway/masto-gateway.mjs +47 -7
- package/lib/surface/routes/owner.mjs +7 -4
- package/package.json +3 -4
- package/scripts/build-app.mjs +3 -0
- package/scripts/check-netlify-bundles.mjs +3 -0
- package/scripts/forum-package.mjs +26 -0
- package/scripts/stage-site.mjs +34 -13
- package/vendor/sanitize-html.cjs +358 -171
- package/web/admin/bar.js +45 -0
- package/web/admin/group.js +17 -0
- package/web/admin/index.html +12 -8
- package/web/admin/record.js +19 -41
- package/web/admin/upkeep.js +3 -0
- package/web/app/README.md +3 -3
- package/web/app/admin-facade.mjs +19 -16
- package/web/app/agent.mjs +65 -2
- package/web/app/atproto-browser.mjs +28 -22
- package/web/app/deliver-relay.mjs +2 -1
- package/web/app/dist/boot.js +1 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +567 -265
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +32 -30
- package/web/app/idb-kv.mjs +4 -4
- package/web/app/shims/prelude.js +1 -0
- package/web/app/sw-src.mjs +44 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// forum-pages.mjs — a forum's page at its own host (bb.<gateway>/<forum>/…).
|
|
2
|
+
// Every address under a forum is handed to the forum's own code
|
|
3
|
+
// (ctx.forum.renderPage, netlify/forum-support.mjs), which draws the ones it
|
|
4
|
+
// draws; for the rest, or when drawing fails, the page as the site serves it,
|
|
5
|
+
// which the reader's browser builds. Which addresses are drawn is the forum's
|
|
6
|
+
// business, not this file's.
|
|
7
|
+
|
|
8
|
+
// A forum's path, and anything under it.
|
|
9
|
+
const VIEW = /^\/([^/]+)\//u;
|
|
10
|
+
// First path segments the bb host gives to something other than a forum.
|
|
11
|
+
const NOT_FORUMS = new Set(['api', 'bb', 'icons', 'u', '.well-known']);
|
|
12
|
+
// How long the edge keeps a drawn page. The forum's documents are dropped
|
|
13
|
+
// from the edge when a post lands and when the forum runs, under the labels
|
|
14
|
+
// the page carries too, so an hour only bounds a change nothing announced.
|
|
15
|
+
// Kept per region, not durably: a page names the scripts of the deploy that
|
|
16
|
+
// drew it.
|
|
17
|
+
const PAGE_EDGE_SECONDS = 3600;
|
|
18
|
+
const MISSING_EDGE_SECONDS = 120;
|
|
19
|
+
|
|
20
|
+
// The page as the site serves it, read once per running instance: it changes
|
|
21
|
+
// only with a deploy, which starts new ones.
|
|
22
|
+
const shells = new Map();
|
|
23
|
+
async function shellOf(origin, f) {
|
|
24
|
+
if (shells.has(origin)) return shells.get(origin);
|
|
25
|
+
const r = await f(`${origin}/bb/index.html`).catch(() => null);
|
|
26
|
+
const text = r?.ok ? await r.text().catch(() => null) : null;
|
|
27
|
+
if (text) shells.set(origin, text);
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The answer for a forum's page at its own host, or null for any other request. */
|
|
32
|
+
export async function routeForumPage(request, ctx) {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
if (!/^bb\./u.test(url.hostname)) return null;
|
|
35
|
+
const m = VIEW.exec(url.pathname);
|
|
36
|
+
if (!m || NOT_FORUMS.has(m[1])) return null;
|
|
37
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: { allow: 'GET, HEAD' }, body: '' };
|
|
38
|
+
const shell = await shellOf(url.origin, ctx.siteFetch || globalThis.fetch.bind(globalThis));
|
|
39
|
+
if (!shell) return { status: 503, headers: { 'content-type': 'text/plain', 'cache-control': 'no-store', 'retry-after': '30' }, body: 'the forum\'s page is not available right now\n' };
|
|
40
|
+
const page = (status, body, headers) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...headers }, body });
|
|
41
|
+
// An address with a query is a sign-in coming back, or nothing a reader
|
|
42
|
+
// follows: the page builds it.
|
|
43
|
+
const draws = !!ctx.forum?.renderPage && !url.search;
|
|
44
|
+
// A drawing not done in time goes on after the page has gone, where the
|
|
45
|
+
// host lets work outlive its answer (ctx.waitUntil): the next reader's
|
|
46
|
+
// drawing then finds the forum's documents at hand.
|
|
47
|
+
const drawn = draws ? await ctx.forum.renderPage({ url: url.href, shell, keepGoing: ctx.waitUntil || null }).catch(() => null) : null;
|
|
48
|
+
if (drawn) {
|
|
49
|
+
const hold = drawn.status === 404 ? MISSING_EDGE_SECONDS : PAGE_EDGE_SECONDS;
|
|
50
|
+
return page(drawn.status, drawn.html, {
|
|
51
|
+
'netlify-cdn-cache-control': `public, s-maxage=${hold}, stale-while-revalidate=${hold * 4}`,
|
|
52
|
+
'netlify-cache-tag': drawn.tags.join(','),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Not drawn. Where drawing was tried, this answer is not kept, so the next
|
|
56
|
+
// reader gets a drawn page; where nothing here draws, it is the same page
|
|
57
|
+
// for everyone and is kept like one.
|
|
58
|
+
return page(200, shell, draws || url.search ? {} : { 'netlify-cdn-cache-control': `public, s-maxage=${PAGE_EDGE_SECONDS}` });
|
|
59
|
+
}
|
|
@@ -23,8 +23,10 @@ import * as podRoot from '../pod/root.mjs';
|
|
|
23
23
|
import * as podPolicy from '../pod/policy.mjs';
|
|
24
24
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
25
25
|
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
|
|
26
|
-
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
|
|
26
|
+
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
|
|
27
|
+
import { routeCheckApi, missingLetter } from './check-letter.mjs';
|
|
27
28
|
import { routeNoticesApi } from './notices.mjs';
|
|
29
|
+
import { routeForumPage } from './forum-pages.mjs';
|
|
28
30
|
import { withSecurityHeaders } from './headers.mjs';
|
|
29
31
|
import { claimedWebId, verifyPodToken } from './token-claims.mjs';
|
|
30
32
|
|
|
@@ -306,7 +308,7 @@ function identFor(rec, policy = null) {
|
|
|
306
308
|
// an adapter that reads a store directly and so
|
|
307
309
|
// must confine the read itself.
|
|
308
310
|
export async function routeFront(request, ctx) {
|
|
309
|
-
const out = await route(request, ctx);
|
|
311
|
+
const out = (await routeForumPage(request, ctx)) || await route(request, ctx);
|
|
310
312
|
return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
|
|
311
313
|
}
|
|
312
314
|
|
|
@@ -574,8 +576,9 @@ async function route(request, ctx) {
|
|
|
574
576
|
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
575
577
|
if (quiet) return withApiCors(quiet);
|
|
576
578
|
// The owner's app saying it is open, taking what was held, and naming what to push (held-mail.mjs).
|
|
577
|
-
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner };
|
|
578
|
-
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned)
|
|
579
|
+
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner, missingLetter: (h) => missingLetter(ctx.mastoKv, h) };
|
|
580
|
+
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned)
|
|
581
|
+
|| await routeAlertApi(request, pathname, ctx, owned) || await routeCheckApi(request, pathname, ctx, owned);
|
|
579
582
|
if (here) return withApiCors(here);
|
|
580
583
|
|
|
581
584
|
// The relay: the front sends requests a browser has already signed. A page
|
|
@@ -211,7 +211,31 @@ export async function routeHereApi(request, pathname, ctx, deps) {
|
|
|
211
211
|
const flushed = await flushHeld(ctx, handle, rec)
|
|
212
212
|
.catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
|
|
213
213
|
// `hold`: with it off, a browser working from a copy goes back to the pod.
|
|
214
|
-
|
|
214
|
+
// `missingLetter`: the gateway's test letter has not arrived (check-letter.mjs).
|
|
215
|
+
const missing = await deps.missingLetter?.(handle).catch(() => null);
|
|
216
|
+
return j(200, { ok: true, handle, flushed, hold: holdOn(ctx), ...(missing ? { missingLetter: missing } : {}) });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// FediPod could not read some of its owner's mail: they are told on the phones
|
|
220
|
+
// and browsers they signed up for notifications, in FediPod's own words.
|
|
221
|
+
// Proved like /api/here. Returns a response, or null for other paths.
|
|
222
|
+
export async function routeAlertApi(request, pathname, ctx, deps) {
|
|
223
|
+
if (pathname !== '/api/alert') return null;
|
|
224
|
+
const { j } = deps;
|
|
225
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
226
|
+
if (request.method !== 'POST') return null;
|
|
227
|
+
let body;
|
|
228
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
229
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
230
|
+
const text = typeof body.text === 'string' ? body.text.trim().slice(0, 200) : '';
|
|
231
|
+
if (!text) return j(400, { error: 'text required' });
|
|
232
|
+
const rec = await ctx.lookup(handle);
|
|
233
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
234
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
235
|
+
if (who.error) return who.error;
|
|
236
|
+
if (!ctx.startPush || !await ctx.pushWanted?.(handle).catch(() => false)) return j(200, { ok: true, handle, pushing: 0 });
|
|
237
|
+
await ctx.startPush(handle, { alert: text }).catch((e) => console.log(`alert @${handle}: not started: ${e?.message || e}`));
|
|
238
|
+
return j(200, { ok: true, handle, pushing: 1 });
|
|
215
239
|
}
|
|
216
240
|
|
|
217
241
|
// FediPod open in a browser reads its own mail, so nothing is held for the
|
package/lib/gateway/keeper.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { flushHeld, heldEntries } from './held-mail.mjs';
|
|
|
19
19
|
import { publishDue, nextDue } from '../core/scheduled.mjs';
|
|
20
20
|
import { lockCopy } from './copy.mjs';
|
|
21
21
|
import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
22
|
+
import { checkLettersFor } from './check-letter.mjs';
|
|
22
23
|
|
|
23
24
|
export { keeperSession };
|
|
24
25
|
|
|
@@ -53,7 +54,7 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
|
|
|
53
54
|
if (!await lease.acquire()) return { skipped: 'another device is acting on this account', flushed, retry: 'soon' };
|
|
54
55
|
try {
|
|
55
56
|
await store.load();
|
|
56
|
-
const agent = await actingAgent(at, store, lease, { log });
|
|
57
|
+
const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
|
|
57
58
|
if (agent.skipped) return { skipped: agent.skipped, flushed };
|
|
58
59
|
let taken = 0;
|
|
59
60
|
if (at.inCopy && ctx.listHeld) {
|
|
@@ -48,6 +48,9 @@ import { CopyStorage, lockCopy, keptNow, keptBefore, holdOn, waitOutClosing, GAT
|
|
|
48
48
|
import { HttpStorage } from '../core/storage.mjs';
|
|
49
49
|
import * as podInbox from '../pod/inbox.mjs';
|
|
50
50
|
import { heldEntries } from './held-mail.mjs';
|
|
51
|
+
import { checkLettersFor } from './check-letter.mjs';
|
|
52
|
+
import { readConnectedForApp } from './connected-feeds.mjs';
|
|
53
|
+
import { TagFeed } from '../connections/tagfeed.mjs';
|
|
51
54
|
import { noteOpened } from './quiet.mjs';
|
|
52
55
|
import { Push } from '../client/webpush.mjs';
|
|
53
56
|
import webpush from 'web-push';
|
|
@@ -282,6 +285,8 @@ function readingAgent(at, store, acting, log) {
|
|
|
282
285
|
const publisher = new Publisher({ config, remote: at.remote, store, deliverer: null, publicKeyPem: null, log });
|
|
283
286
|
return {
|
|
284
287
|
store, publisher, remote: at.remote, config, viewer: false, copy: null,
|
|
288
|
+
// The hashtags followed, for the app to show; FediPod in the browser reads them.
|
|
289
|
+
tagfeed: new TagFeed({ store, intake: null, log }),
|
|
285
290
|
configured: () => !!store.getConfig(), requestTakeover: async () => true, onScheduled: () => {},
|
|
286
291
|
intake: { fetchAP: async (u) => (await acting()).intake.fetchAP(u) },
|
|
287
292
|
};
|
|
@@ -334,7 +339,7 @@ async function readHeld(ctx, handle, rec, { log = console.log, takeover = false,
|
|
|
334
339
|
const { store, lease } = stateAndLease(ctx, handle, at, { log });
|
|
335
340
|
if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
|
|
336
341
|
await store.load();
|
|
337
|
-
const agent = await actingAgent(at, store, lease, { log });
|
|
342
|
+
const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
|
|
338
343
|
if (agent.skipped) return 0;
|
|
339
344
|
const pushes = [];
|
|
340
345
|
if (await pushWanted(ctx, handle)) {
|
|
@@ -433,17 +438,50 @@ export async function pushMade(ctx, handle, rec, ids, { log = console.log, now =
|
|
|
433
438
|
return fresh.length;
|
|
434
439
|
}
|
|
435
440
|
|
|
441
|
+
/**
|
|
442
|
+
* For FediPod, when it could not read some of its owner's mail: one
|
|
443
|
+
* notification, in its own words, to every phone and browser signed up for
|
|
444
|
+
* them. Read from the account as it is now, without acting.
|
|
445
|
+
*/
|
|
446
|
+
export async function pushAlert(ctx, handle, rec, text, { log = console.log } = {}) {
|
|
447
|
+
if (!text || !await pushWanted(ctx, handle)) return 0;
|
|
448
|
+
await waitOutClosing(ctx.copyKv, handle);
|
|
449
|
+
const at = await reachAccount(ctx, handle, rec, { log });
|
|
450
|
+
if (at.skipped) return 0;
|
|
451
|
+
const store = await readingStore(ctx, handle, at, log);
|
|
452
|
+
const agent = readingAgent(at, store, async () => { throw new Error('only reading'); }, log);
|
|
453
|
+
if (!agent) return 0;
|
|
454
|
+
if (!at.inCopy) await store.refresh(['webpush.json'], { ifChanged: true });
|
|
455
|
+
const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx), { keep: false });
|
|
456
|
+
const id = crypto.randomBytes(8).toString('hex');
|
|
457
|
+
await api.push.notify({ id, type: 'mention' }, {
|
|
458
|
+
notification_id: id, notification_type: 'mention', preferred_locale: 'en',
|
|
459
|
+
title: 'FediPod', body: String(text).slice(0, 200), icon: '',
|
|
460
|
+
});
|
|
461
|
+
log(`@${handle}: alert pushed`);
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
|
|
436
465
|
// A request from an app, answered by the account's facade over its copy, or
|
|
437
466
|
// with the hold off, over the pod.
|
|
438
467
|
async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
439
468
|
const handle = tok.handle;
|
|
440
469
|
forgetOld();
|
|
441
470
|
const repeatKey = request.method === 'GET' && REPEATABLE.test(url.pathname) ? `${handle}\n${tokenHash}\n${url.pathname}${url.search}` : null;
|
|
471
|
+
// An app checking in: new mail, and the owner's connected accounts when due,
|
|
472
|
+
// read in beside this answer for the app's next check.
|
|
473
|
+
const checksIn = request.method === 'GET' && /^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname);
|
|
474
|
+
const readConnected = () => readConnectedForApp(ctx, handle, rec, { log })
|
|
475
|
+
.then((n) => { if (n) forget(handle); })
|
|
476
|
+
.catch((e) => log(`app @${handle}: connected accounts: ${e.message}`));
|
|
442
477
|
// The copy's listing as it was: the same answer, without opening the copy.
|
|
443
478
|
// A copy written to the pod and deleted since lists differently.
|
|
444
479
|
if (repeatKey && holdOn(ctx)) {
|
|
445
480
|
const again = await repeatAnswer(ctx, handle, repeatKey, { inCopy: true });
|
|
446
|
-
if (again)
|
|
481
|
+
if (again) {
|
|
482
|
+
if (checksIn) ctx.waitUntil?.(readConnected());
|
|
483
|
+
return again;
|
|
484
|
+
}
|
|
447
485
|
}
|
|
448
486
|
// A copy the round is writing to the pod and deleting is waited out; it is
|
|
449
487
|
// made again below.
|
|
@@ -457,9 +495,11 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
|
457
495
|
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
458
496
|
at.inCopy = true;
|
|
459
497
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
498
|
+
if (checksIn) {
|
|
499
|
+
ctx.waitUntil?.((async () => {
|
|
500
|
+
await readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`));
|
|
501
|
+
await readConnected();
|
|
502
|
+
})());
|
|
463
503
|
}
|
|
464
504
|
// With the hold off, the pod's listing, after the inbox has been looked at.
|
|
465
505
|
if (repeatKey && !holdOn(ctx)) {
|
|
@@ -475,7 +515,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
|
475
515
|
const acting = () => (actingP ||= (async () => {
|
|
476
516
|
const s = stateAndLease(ctx, handle, at, { log });
|
|
477
517
|
await s.store.load();
|
|
478
|
-
const a = await actingAgent(at, s.store, s.lease, { log });
|
|
518
|
+
const a = await actingAgent(at, s.store, s.lease, { log, checkLetters: checkLettersFor(ctx, handle) });
|
|
479
519
|
if (a.skipped) throw new Error(a.skipped);
|
|
480
520
|
return a;
|
|
481
521
|
})());
|
|
@@ -505,7 +545,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
|
505
545
|
lease = s.lease;
|
|
506
546
|
const store = s.store;
|
|
507
547
|
await store.load();
|
|
508
|
-
const agent = await actingAgent(at, store, lease, { log });
|
|
548
|
+
const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
|
|
509
549
|
if (agent.skipped) return json(503, { error: agent.skipped });
|
|
510
550
|
const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
|
|
511
551
|
const handled = await api.handle(b.req, b.res, url.pathname, url);
|
|
@@ -13,7 +13,7 @@ import { AGENT_VERSION } from '../static.mjs';
|
|
|
13
13
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
14
14
|
// Config the actor document carries, so a change is not real until it is
|
|
15
15
|
// republished.
|
|
16
|
-
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators'];
|
|
16
|
+
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators', 'autoAcceptFollows'];
|
|
17
17
|
|
|
18
18
|
export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
|
|
19
19
|
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
@@ -70,6 +70,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
|
|
|
70
70
|
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
71
71
|
aliases: cfg.aliases || [],
|
|
72
72
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
73
|
+
tagsInHome: cfg.tagsInHome !== false,
|
|
73
74
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
74
75
|
// Per-machine, so it comes from the credential file, not pod config —
|
|
75
76
|
// and it is a fact here, not a setting: moving it means moving data,
|
|
@@ -189,10 +190,12 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
189
190
|
if ('approveJoins' in body) cfg.approveJoins = !!body.approveJoins;
|
|
190
191
|
if ('review' in body) cfg.review = !!body.review;
|
|
191
192
|
// A person's queue gate: on, and an inbound Follow is accepted on
|
|
192
|
-
// arrival instead of waiting in the requests queue.
|
|
193
|
-
//
|
|
194
|
-
// patched below
|
|
193
|
+
// arrival instead of waiting in the requests queue. The actor says
|
|
194
|
+
// which (manuallyApprovesFollowers), and intake reads the LIVE config,
|
|
195
|
+
// so it is patched below as well as republished.
|
|
195
196
|
if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
|
|
197
|
+
// Whether hashtag posts appear in Home; read as each timeline is answered.
|
|
198
|
+
if ('tagsInHome' in body) cfg.tagsInHome = !!body.tagsInHome;
|
|
196
199
|
// The moderator roster (FEP-1b12): actor IRIs whose federated
|
|
197
200
|
// moderation asks are queued, published as attributedTo.
|
|
198
201
|
let rosterDiff = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
|
@@ -97,7 +97,6 @@
|
|
|
97
97
|
"package-lock.json": "npm:empty-npm-package@1.0.0"
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|
|
100
|
-
"esbuild": "^0.28.2"
|
|
101
|
-
"fedipod-bb": "^0.4.1"
|
|
100
|
+
"esbuild": "^0.28.2"
|
|
102
101
|
}
|
|
103
102
|
}
|
package/scripts/build-app.mjs
CHANGED
|
@@ -67,6 +67,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
67
67
|
const entry = args[args.indexOf('--entry') + 1] || path.join(root, 'web/app/agent.mjs');
|
|
68
68
|
const out = args[args.indexOf('--out') + 1] || path.join(root, 'web/app/dist/agent.js');
|
|
69
69
|
const format = args.includes('--format') ? args[args.indexOf('--format') + 1] : 'esm';
|
|
70
|
+
// The app's "make a forum" is built from the forum's own folder, as it is now.
|
|
71
|
+
const { bringForum } = await import('./forum-package.mjs');
|
|
72
|
+
bringForum();
|
|
70
73
|
const { bytes } = await buildApp({ entry, out, format });
|
|
71
74
|
console.log(`built ${path.relative(root, out)} — ${Math.round(bytes / 1024)} KB`);
|
|
72
75
|
}
|
|
@@ -18,6 +18,9 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
|
|
|
18
18
|
let fails = 0;
|
|
19
19
|
const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
|
|
20
20
|
try {
|
|
21
|
+
// The functions carry the forum as it is in its own folder now.
|
|
22
|
+
const { bringForum } = await import('./forum-package.mjs');
|
|
23
|
+
bringForum();
|
|
21
24
|
execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
|
|
22
25
|
{ cwd: root, stdio: 'ignore' });
|
|
23
26
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// forum-package.mjs — the forum (fedipod-bb) that fedipod.net runs and
|
|
2
|
+
// serves, taken from the forum's own folder every time, not from a version
|
|
3
|
+
// FediPod pins: the checkout beside FediPod, or a clone of the forum's GitHub
|
|
4
|
+
// main where there is none (scripts/dependents.mjs). It is packed as npm would
|
|
5
|
+
// publish it and put in node_modules without touching FediPod's package list,
|
|
6
|
+
// so nothing FediPod records names a forum version. Staging the site, the
|
|
7
|
+
// bundle check and the app build each bring it first.
|
|
8
|
+
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { fetchDependent, root } from './dependents.mjs';
|
|
11
|
+
|
|
12
|
+
/** The forum from its folder, installed in node_modules. Returns its folder and version. */
|
|
13
|
+
export function bringForum({ log = console.log } = {}) {
|
|
14
|
+
const dir = fetchDependent('fedipod-bb', { log });
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forum-pack-'));
|
|
16
|
+
try {
|
|
17
|
+
const packed = JSON.parse(execFileSync('npm', ['pack', dir, '--pack-destination', tmp, '--ignore-scripts', '--json'],
|
|
18
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
|
|
19
|
+
execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', path.join(tmp, packed.filename)],
|
|
20
|
+
{ cwd: root, stdio: 'ignore' });
|
|
21
|
+
log(`forum: fedipod-bb ${packed.version} from ${path.relative(root, dir) || dir}`);
|
|
22
|
+
return { dir, version: packed.version };
|
|
23
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (import.meta.url === `file://${process.argv[1]}`) bringForum();
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import { readyDependent } from './dependents.mjs';
|
|
17
|
+
import { bringForum } from './forum-package.mjs';
|
|
17
18
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
19
|
const site = path.join(root, 'web/app/site');
|
|
20
|
+
// The forum's page is copied from the forum's own folder, as it is now.
|
|
21
|
+
bringForum({ log: () => {} });
|
|
19
22
|
fs.rmSync(site, { recursive: true, force: true }); fs.mkdirSync(path.join(site, 'app'), { recursive: true });
|
|
20
23
|
const cp = (from, to) => fs.copyFileSync(path.resolve(root, from), path.join(site, to));
|
|
21
24
|
cp('web/app/index.html', 'index.html');
|
|
@@ -152,8 +155,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
152
155
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
153
156
|
.replace('</body>', `${clientNews()}</body>`);
|
|
154
157
|
// Hide controls the browser build does not carry: the manual drain (it
|
|
155
|
-
// drains automatically over notifications), the
|
|
156
|
-
// (there is no local log in a browser), and the "create a handle at the
|
|
158
|
+
// drains automatically over notifications), and the "create a handle at the
|
|
157
159
|
// gateway" choice — a fronted @you@front identity is not the browser
|
|
158
160
|
// model, and admin-facade.mjs refuses one outright. Leaving the radio
|
|
159
161
|
// visible meant the page checked the name's availability as you typed,
|
|
@@ -162,7 +164,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
162
164
|
// And rename "Recover posts" to the plainer "Refresh Feed" for this
|
|
163
165
|
// audience.
|
|
164
166
|
text = text
|
|
165
|
-
.replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#
|
|
167
|
+
.replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#actor-pick')
|
|
166
168
|
.replace('>Recover posts<', '>Refresh Feed<')
|
|
167
169
|
// The move-to-another-gateway hint: only the browser build can move
|
|
168
170
|
// in (signup.mjs moveIn), so only here does gateway.js get to show it.
|
|
@@ -220,6 +222,12 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
220
222
|
`https://${h}/update.js /update.js 200!`,
|
|
221
223
|
`https://${h}/build.json /build.json 200!`,
|
|
222
224
|
`https://${h}/api/* /.netlify/functions/front 200!`,
|
|
225
|
+
// The forum's host answers /robots.txt with the forum's own.
|
|
226
|
+
`https://${h}/robots.txt /bb/robots.txt 200!`,
|
|
227
|
+
// Every address under a forum goes to the forum's code, which draws the
|
|
228
|
+
// ones it draws (lib/gateway/forum-pages.mjs); the rest get the page.
|
|
229
|
+
`https://${h}/:forum/ /.netlify/functions/front 200!`,
|
|
230
|
+
`https://${h}/:forum/* /.netlify/functions/front 200!`,
|
|
223
231
|
`https://${h}/* /bb/index.html 200!`,
|
|
224
232
|
]),
|
|
225
233
|
'# the in-browser app, served static (these win over the project catch-all)',
|
|
@@ -268,7 +276,13 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
268
276
|
// own /u/<handle>/ or a pod named outright. Its script is a module file of
|
|
269
277
|
// its own, so it is served under the same `script-src 'self'` as the app.
|
|
270
278
|
fs.mkdirSync(path.join(site, 'bb'), { recursive: true });
|
|
271
|
-
|
|
279
|
+
// Every file the forum's page ships, whatever the forum names them (its
|
|
280
|
+
// robots.txt among them): the forum decides what its page is made of.
|
|
281
|
+
// Editor leftovers and hidden files are not part of it.
|
|
282
|
+
for (const f of fs.readdirSync(path.join(root, 'node_modules/fedipod-bb/site'))) {
|
|
283
|
+
if (/^[.#]|~$/u.test(f)) continue;
|
|
284
|
+
cp(`node_modules/fedipod-bb/site/${f}`, `bb/${f}`);
|
|
285
|
+
}
|
|
272
286
|
cp('web/admin/tokens.css', 'bb/tokens.css'); // the site's shared palette, size and family
|
|
273
287
|
cp('node_modules/fediverse-session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
|
|
274
288
|
// The account library's demo page, with the library beside it, under /demo/:
|
|
@@ -293,15 +307,22 @@ cp(path.join(sessionRepo, 'demo.html'), 'demo/index.html');
|
|
|
293
307
|
const sub = (n, pairs) => { let t = fs.readFileSync(bb(n), 'utf8');
|
|
294
308
|
for (const [a, b] of pairs) t = t.split(a).join(b); fs.writeFileSync(bb(n), t); };
|
|
295
309
|
sub('oidc-session.mjs', [["'fediverse-session/oidc-session.mjs'", `'./solid-oidc-session.mjs?v=${stamp('solid-oidc-session.mjs')}'`]]);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
// Every module names the others it loads by their stamped address, and each
|
|
311
|
+
// is stamped only after the ones it names, so one module is one address
|
|
312
|
+
// wherever it is loaded from.
|
|
313
|
+
const modules = fs.readdirSync(path.join(site, 'bb')).filter((n) => /\.m?js$/u.test(n));
|
|
314
|
+
const loads = (n) => [...fs.readFileSync(bb(n), 'utf8').matchAll(/from '\.\/([^'?]+)'/gu)].map((m) => m[1]).filter((m) => modules.includes(m));
|
|
315
|
+
const done = new Set();
|
|
316
|
+
while (done.size < modules.length) {
|
|
317
|
+
const ready = modules.filter((n) => !done.has(n) && loads(n).every((m) => done.has(m)));
|
|
318
|
+
if (!ready.length) throw new Error(`stage-site: the forum page's modules load each other in a circle (${modules.filter((n) => !done.has(n)).join(', ')})`);
|
|
319
|
+
for (const n of ready) {
|
|
320
|
+
sub(n, loads(n).map((m) => [`'./${m}'`, `'./${m}?v=${stamp(m)}'`]));
|
|
321
|
+
done.add(n);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
sub('index.html', [...fs.readFileSync(bb('index.html'), 'utf8').matchAll(/"\/bb\/([^"?]+)"/gu)].map((m) => m[1])
|
|
325
|
+
.filter((n) => fs.existsSync(bb(n))).map((n) => [`"/bb/${n}"`, `"/bb/${n}?v=${stamp(n)}"`]));
|
|
305
326
|
}
|
|
306
327
|
injectUpdate(path.join(site, 'bb/index.html'));
|
|
307
328
|
injectUpdate(path.join(site, 'index.html'));
|