fedipod 1.50.0 → 1.55.2
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/render.mjs +11 -4
- 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/graphview.mjs +9 -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/core/publisher/restore.mjs +1 -0
- package/lib/core/store.mjs +27 -6
- 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/front-core.mjs +5 -3
- 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 -3
- package/scripts/stage-site.mjs +2 -3
- 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 +68 -3
- 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 +601 -273
- 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,123 @@
|
|
|
1
|
+
// check-letter.mjs — the gateway's test letter. Every six hours it writes to
|
|
2
|
+
// its admin's own account through the account's door, as any server would,
|
|
3
|
+
// and FediPod answers once it has taken the letter in (POST /api/check), or
|
|
4
|
+
// the gateway's own reader does when it reads held mail (account-agent.mjs).
|
|
5
|
+
// A letter still unanswered after FediPod has been open an hour means mail is
|
|
6
|
+
// not getting through, and the owner's phones are told. The letter itself is
|
|
7
|
+
// shown nowhere (lib/core/intake: checkLetters).
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { PRESENT_MS } from './held-mail.mjs';
|
|
10
|
+
|
|
11
|
+
export const LETTER_EVERY_MS = 6 * 3600_000;
|
|
12
|
+
export const ANSWER_WITHIN_MS = 3600_000;
|
|
13
|
+
const LETTER_ID = /^https?:\/\/[^/]+\/check\/letters\/[0-9a-f]{24}$/u;
|
|
14
|
+
|
|
15
|
+
const base = (origin) => String(origin || '').replace(/\/$/u, '');
|
|
16
|
+
export const checkActor = (origin) => `${base(origin)}/check/actor`;
|
|
17
|
+
const stateKey = (handle) => `check/${handle}`;
|
|
18
|
+
const read = async (kv, handle) => { try { return JSON.parse((await kv.get(stateKey(handle)))?.text || 'null') || {}; } catch { return {}; } };
|
|
19
|
+
const save = (kv, handle, st) => kv.set(stateKey(handle), JSON.stringify(st));
|
|
20
|
+
|
|
21
|
+
// Who gets letters: the admin's own browser accounts, still at this gateway.
|
|
22
|
+
const checked = (ctx, rec) => !!(ctx.adminWebId && rec?.openedAt && rec.actorUrl && rec.webId === ctx.adminWebId && !rec.movedTo);
|
|
23
|
+
|
|
24
|
+
/** The letter, as the door takes it: a direct note to the account. */
|
|
25
|
+
export function letterFor(origin, letter, to) {
|
|
26
|
+
const actor = checkActor(origin);
|
|
27
|
+
return {
|
|
28
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
29
|
+
id: letter, type: 'Create', actor, to: [to],
|
|
30
|
+
object: { id: `${letter}#note`, type: 'Note', attributedTo: actor, to: [to], content: '<p>A test letter from this gateway.</p>' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The round's part: for each account checked, a letter unanswered after an
|
|
36
|
+
* hour of FediPod open is alerted once, and a new one goes when none is
|
|
37
|
+
* waiting and six hours have passed since the last. `send(handle, rec, doc)`
|
|
38
|
+
* delivers a letter; `alert(handle, text)` tells the owner's phones.
|
|
39
|
+
*/
|
|
40
|
+
export async function roundChecks(ctx, { kv = ctx.mastoKv, now = Date.now(), send, alert, log = console.log } = {}) {
|
|
41
|
+
if (!kv || !ctx.adminWebId || !ctx.listDirectory || !ctx.frontOrigin) return;
|
|
42
|
+
const rows = await ctx.listDirectory();
|
|
43
|
+
for (const [handle, rec] of Object.entries(rows || {})) {
|
|
44
|
+
if (!checked(ctx, rec)) continue;
|
|
45
|
+
const st = await read(kv, handle);
|
|
46
|
+
const presentAt = Number(await ctx.presentAt?.(handle).catch(() => 0)) || 0;
|
|
47
|
+
const open = !!presentAt && now - presentAt < PRESENT_MS;
|
|
48
|
+
const waiting = st.letter && !st.answeredAt && !st.alertedAt;
|
|
49
|
+
if (waiting) {
|
|
50
|
+
// Open since this round, or since an earlier one; closed starts it again.
|
|
51
|
+
st.openSince = open ? (st.openSince ?? now) : null;
|
|
52
|
+
if (open && now - st.openSince >= ANSWER_WITHIN_MS) {
|
|
53
|
+
st.alertedAt = now;
|
|
54
|
+
await alert(handle, 'Its test letter has not reached your FediPod after an hour with FediPod open. Mail may not be getting through.')
|
|
55
|
+
.catch((e) => log(`check @${handle}: alert not sent: ${e?.message || e}`));
|
|
56
|
+
log(`check @${handle}: letter not answered after an hour open — owner told`);
|
|
57
|
+
}
|
|
58
|
+
await save(kv, handle, st);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (typeof st.sentAt === 'number' && now - st.sentAt < LETTER_EVERY_MS) continue;
|
|
62
|
+
const letter = `${base(ctx.frontOrigin)}/check/letters/${crypto.randomBytes(12).toString('hex')}`;
|
|
63
|
+
const status = await send(handle, rec, letterFor(ctx.frontOrigin, letter, rec.actorUrl)).catch((e) => { log(`check @${handle}: letter not sent: ${e?.message || e}`); return 0; });
|
|
64
|
+
if (status !== 202) { log(`check @${handle}: the door answered ${status} to the letter`); continue; }
|
|
65
|
+
await save(kv, handle, { letter, sentAt: now });
|
|
66
|
+
log(`check @${handle}: letter sent`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The letter taken in: true when it is the one waiting. */
|
|
71
|
+
export async function answerLetter(kv, handle, letter, { now = Date.now() } = {}) {
|
|
72
|
+
if (!kv || !LETTER_ID.test(String(letter || ''))) return false;
|
|
73
|
+
const st = await read(kv, handle);
|
|
74
|
+
if (st.letter !== letter) return false;
|
|
75
|
+
if (!st.answeredAt) await save(kv, handle, { ...st, answeredAt: now });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** When the letter waiting was alerted and is still unanswered, its time; else null. */
|
|
80
|
+
export async function missingLetter(kv, handle) {
|
|
81
|
+
if (!kv) return null;
|
|
82
|
+
const st = await read(kv, handle);
|
|
83
|
+
return st.alertedAt && !st.answeredAt ? new Date(st.alertedAt).toISOString() : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the gateway's reader needs to take a letter in at the gateway itself. */
|
|
87
|
+
export const checkLettersFor = (ctx, handle) => (ctx?.frontOrigin && ctx.mastoKv
|
|
88
|
+
? { actor: checkActor(ctx.frontOrigin), taken: async (letter) => { await answerLetter(ctx.mastoKv, handle, letter); } }
|
|
89
|
+
: null);
|
|
90
|
+
|
|
91
|
+
// The letter's sender, for anything that looks it up: an application, whose
|
|
92
|
+
// inbox takes and keeps nothing. Returns a response, or null for other paths.
|
|
93
|
+
export function routeCheckActor(request, pathname, ctx, { j }) {
|
|
94
|
+
if (pathname === '/check/actor' && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
95
|
+
const id = checkActor(ctx.frontOrigin || new URL(request.url).origin);
|
|
96
|
+
return j(200, {
|
|
97
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
98
|
+
id, type: 'Application', preferredUsername: 'check', name: `${ctx.host || new URL(id).host} test letters`,
|
|
99
|
+
inbox: `${base(new URL(id).origin)}/check/inbox`,
|
|
100
|
+
}, 'application/activity+json');
|
|
101
|
+
}
|
|
102
|
+
if (pathname === '/check/inbox' && request.method === 'POST') return { status: 202, headers: {}, body: null };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The owner's FediPod, saying it took the letter in, proved like /api/here;
|
|
107
|
+
// and the letter's sender, for anything that looks it up.
|
|
108
|
+
export async function routeCheckApi(request, pathname, ctx, deps) {
|
|
109
|
+
if (pathname.startsWith('/check/')) return routeCheckActor(request, pathname, ctx, deps);
|
|
110
|
+
if (pathname !== '/api/check') return null;
|
|
111
|
+
const { j } = deps;
|
|
112
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
113
|
+
if (request.method !== 'POST') return null;
|
|
114
|
+
let body;
|
|
115
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
116
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
117
|
+
const rec = await ctx.lookup(handle);
|
|
118
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
119
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
120
|
+
if (who.error) return who.error;
|
|
121
|
+
const answered = await answerLetter(ctx.mastoKv, handle, String(body.letter || ''));
|
|
122
|
+
return j(200, { ok: true, handle, answered });
|
|
123
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// connected-feeds.mjs — the owner's other accounts read into theirs at the
|
|
2
|
+
// gateway, for an app such as Elk while FediPod is closed in the browser.
|
|
3
|
+
//
|
|
4
|
+
// FediPod in the browser reads the owner's connected Mastodon-API accounts and
|
|
5
|
+
// Bluesky account every five minutes while it is open (acctfeed.mjs,
|
|
6
|
+
// bskyfeed.mjs). Here the same reads are made, at most every five minutes,
|
|
7
|
+
// when an app asks for the home timeline or the notifications and nobody else
|
|
8
|
+
// holds the account. The logins are read from the pod for the one read and are
|
|
9
|
+
// never kept at the gateway (lib/core/pod-only.mjs); a login the far server
|
|
10
|
+
// renews goes back to the pod.
|
|
11
|
+
import { FediAccounts } from '../connections/fediacct.mjs';
|
|
12
|
+
import { AcctFeed } from '../connections/acctfeed.mjs';
|
|
13
|
+
import { Atproto } from '../connections/atproto.mjs';
|
|
14
|
+
import { BskyFeed } from '../connections/bskyfeed.mjs';
|
|
15
|
+
import { safeFetch } from '../shared/safefetch.mjs';
|
|
16
|
+
import { apUrls } from '../core/wire.mjs';
|
|
17
|
+
import { lockCopy, heldByBrowser } from './copy.mjs';
|
|
18
|
+
import { reachAccount, settle, stateAndLease } from './account-agent.mjs';
|
|
19
|
+
|
|
20
|
+
export const FEEDS_EVERY_MS = 5 * 60_000;
|
|
21
|
+
// An account with nothing connected is looked at again only this often.
|
|
22
|
+
export const NONE_EVERY_MS = 60 * 60_000;
|
|
23
|
+
// Each request to another server is cut short, and Bluesky is not started
|
|
24
|
+
// once this much of the read has gone, so an app's write waiting on the
|
|
25
|
+
// account does not wait long.
|
|
26
|
+
const ASK_MS = 6_000;
|
|
27
|
+
const READ_MS = 15_000;
|
|
28
|
+
const CONNECTED = ['fediaccts.json', 'atproto.json'];
|
|
29
|
+
|
|
30
|
+
// The logins as FediPod in the browser keeps them: one document for the
|
|
31
|
+
// Mastodon-API accounts, by id, and one for Bluesky.
|
|
32
|
+
function accountsVault(store) {
|
|
33
|
+
const all = () => store.read('fediaccts.json', {}) || {};
|
|
34
|
+
return {
|
|
35
|
+
names: () => Object.keys(all()),
|
|
36
|
+
read: (id) => all()[id] || null,
|
|
37
|
+
write: (id, rec) => store.write('fediaccts.json', { ...all(), [id]: { ...rec, storage: 'pod' } }),
|
|
38
|
+
remove: async () => false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const NO_APPS = { names: () => [], read: () => null, write: () => {}, remove: async () => false };
|
|
42
|
+
function blueskyVault(store) {
|
|
43
|
+
return {
|
|
44
|
+
names: () => (store.read('atproto.json', null) ? ['bluesky'] : []),
|
|
45
|
+
read: () => store.read('atproto.json', null),
|
|
46
|
+
write: (_, rec) => store.write('atproto.json', { ...rec, storage: 'pod' }),
|
|
47
|
+
remove: async () => false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dueKey = (handle) => `${handle}/feeds-due`;
|
|
52
|
+
|
|
53
|
+
/** For an app checking in: when due, and never over FediPod open in a browser. */
|
|
54
|
+
export async function readConnectedForApp(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
|
|
55
|
+
if (!ctx.copyKv) return 0;
|
|
56
|
+
const due = async () => Date.now() >= Number((await ctx.copyKv.get(dueKey(handle)))?.text || 0);
|
|
57
|
+
// FediPod open in a browser reads them itself; the pod is not asked.
|
|
58
|
+
if (!await due() || await heldByBrowser(ctx.copyKv, handle)) return 0;
|
|
59
|
+
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs: 1000 });
|
|
60
|
+
if (!unlock) return 0;
|
|
61
|
+
try {
|
|
62
|
+
// Another request may have read them while this one waited.
|
|
63
|
+
if (!await due()) return 0;
|
|
64
|
+
await ctx.copyKv.set(dueKey(handle), String(Date.now() + FEEDS_EVERY_MS));
|
|
65
|
+
const out = await readConnected(ctx, handle, rec, { log, fetcher, session });
|
|
66
|
+
if (out.none) await ctx.copyKv.set(dueKey(handle), String(Date.now() + NONE_EVERY_MS));
|
|
67
|
+
return out.added || 0;
|
|
68
|
+
} finally { await unlock(); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One read of every connected account into the account, by a caller holding
|
|
73
|
+
* lockCopy. Returns { added } or { none } when nothing is connected.
|
|
74
|
+
*/
|
|
75
|
+
export async function readConnected(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
|
|
76
|
+
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
77
|
+
if (at.skipped) return { added: 0 };
|
|
78
|
+
if (!(await settle(ctx, handle, rec, at, { log })).ok) return { added: 0 };
|
|
79
|
+
// The key is not needed to read: only the logins come from the pod.
|
|
80
|
+
const { store, lease } = stateAndLease(ctx, handle, at, { log, podNames: [] });
|
|
81
|
+
try { await store.refresh(CONNECTED); } catch (e) { log(`@${handle}: connected accounts: ${e.message}`); return { added: 0 }; }
|
|
82
|
+
const out = fetcher || ctx.fetchImpl || null;
|
|
83
|
+
const ask = (url, init = {}) => {
|
|
84
|
+
const cut = { ...init, signal: AbortSignal.timeout(ASK_MS) };
|
|
85
|
+
return out ? out(url, cut) : safeFetch(url, cut);
|
|
86
|
+
};
|
|
87
|
+
const said = (m) => log(`@${handle}: ${m}`);
|
|
88
|
+
const masto = new FediAccounts({ vault: accountsVault(store), apps: NO_APPS, log: said, fetcher: ask });
|
|
89
|
+
const bsky = new Atproto({ vault: blueskyVault(store), log: said, fetcher: ask });
|
|
90
|
+
const bskyOn = () => bsky.connected() && !bsky.read()?.feedPaused;
|
|
91
|
+
if (!masto.connected() && !bskyOn()) return { none: true };
|
|
92
|
+
if (!await lease.acquire()) return { added: 0 };
|
|
93
|
+
try {
|
|
94
|
+
await store.load();
|
|
95
|
+
const config = store.getConfig();
|
|
96
|
+
if (!config || config.quiescedAt) return { added: 0 };
|
|
97
|
+
// Stamped as FediPod in the browser stamps them, so a login made for
|
|
98
|
+
// another actor is left alone here too.
|
|
99
|
+
const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
100
|
+
masto.actorId = bsky.actorId = apUrls(at.pod, config.root || at.root, { publicBase }).actor;
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
let added = 0;
|
|
103
|
+
if (masto.connected()) {
|
|
104
|
+
const feed = new AcctFeed({ store, accounts: masto, log: said });
|
|
105
|
+
await feed.sweep();
|
|
106
|
+
added += feed.lastAdded;
|
|
107
|
+
}
|
|
108
|
+
if (bskyOn() && Date.now() - started < READ_MS) {
|
|
109
|
+
const feed = new BskyFeed({ store, atproto: bsky, log: said });
|
|
110
|
+
await feed.sweep();
|
|
111
|
+
added += feed.lastAdded;
|
|
112
|
+
}
|
|
113
|
+
if (!await store.commit()) said('connected accounts read, but not written to the copy');
|
|
114
|
+
return { added };
|
|
115
|
+
} finally { await lease.release().catch(() => {}); }
|
|
116
|
+
}
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -375,6 +375,25 @@ export async function flushCopy(kv, h, { pod, log = () => {} }) {
|
|
|
375
375
|
let changed = false;
|
|
376
376
|
for (const b of docs) {
|
|
377
377
|
const name = b.key.slice(`${h}/d/`.length);
|
|
378
|
+
// A pod-only document in the copy was put there before it was pod-only.
|
|
379
|
+
// It goes to the pod unless the pod's is newer (changed since the copy
|
|
380
|
+
// last wrote it, or made since the copy was), then out of the copy and
|
|
381
|
+
// its record.
|
|
382
|
+
if (podOnly(name)) {
|
|
383
|
+
const got = await kv.get(b.key);
|
|
384
|
+
const there = typeof pod.read === 'function' ? await pod.read(name).catch(() => null) : null;
|
|
385
|
+
const podMoved = !!there?.ok && (!flushed[name] || hashOf(there.body) !== flushed[name].hash);
|
|
386
|
+
if (got && !podMoved && flushed[name]?.hash !== hashOf(got.text)) {
|
|
387
|
+
const w = await pod.write(name, got.text, 'application/json');
|
|
388
|
+
if (!w.ok) { log(`copy @${h}: ${name} not written to the pod (${w.why})`); continue; }
|
|
389
|
+
n++;
|
|
390
|
+
}
|
|
391
|
+
await kv.delete(b.key);
|
|
392
|
+
delete flushed[name];
|
|
393
|
+
changed = true;
|
|
394
|
+
log(`copy @${h}: ${name} is kept on the pod only now, and left the copy`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
378
397
|
if (flushed[name]?.etag === b.etag) continue;
|
|
379
398
|
const got = await kv.get(b.key);
|
|
380
399
|
if (!got) continue;
|
|
@@ -23,7 +23,8 @@ 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';
|
|
28
29
|
import { withSecurityHeaders } from './headers.mjs';
|
|
29
30
|
import { claimedWebId, verifyPodToken } from './token-claims.mjs';
|
|
@@ -574,8 +575,9 @@ async function route(request, ctx) {
|
|
|
574
575
|
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
575
576
|
if (quiet) return withApiCors(quiet);
|
|
576
577
|
// 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)
|
|
578
|
+
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner, missingLetter: (h) => missingLetter(ctx.mastoKv, h) };
|
|
579
|
+
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned)
|
|
580
|
+
|| await routeAlertApi(request, pathname, ctx, owned) || await routeCheckApi(request, pathname, ctx, owned);
|
|
579
581
|
if (here) return withApiCors(here);
|
|
580
582
|
|
|
581
583
|
// 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.55.2",
|
|
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 && 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 && 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",
|
|
@@ -98,6 +98,6 @@
|
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|
|
100
100
|
"esbuild": "^0.28.2",
|
|
101
|
-
"fedipod-bb": "^0.4.
|
|
101
|
+
"fedipod-bb": "^0.4.4"
|
|
102
102
|
}
|
|
103
103
|
}
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -152,8 +152,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
152
152
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
153
153
|
.replace('</body>', `${clientNews()}</body>`);
|
|
154
154
|
// 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
|
|
155
|
+
// drains automatically over notifications), and the "create a handle at the
|
|
157
156
|
// gateway" choice — a fronted @you@front identity is not the browser
|
|
158
157
|
// model, and admin-facade.mjs refuses one outright. Leaving the radio
|
|
159
158
|
// visible meant the page checked the name's availability as you typed,
|
|
@@ -162,7 +161,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
162
161
|
// And rename "Recover posts" to the plainer "Refresh Feed" for this
|
|
163
162
|
// audience.
|
|
164
163
|
text = text
|
|
165
|
-
.replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#
|
|
164
|
+
.replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#actor-pick')
|
|
166
165
|
.replace('>Recover posts<', '>Refresh Feed<')
|
|
167
166
|
// The move-to-another-gateway hint: only the browser build can move
|
|
168
167
|
// in (signup.mjs moveIn), so only here does gateway.js get to show it.
|