fedipod 0.8.0 → 0.10.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/bin/fedipod.mjs +33 -1
- package/cli.md +11 -3
- package/gui.md +15 -1
- package/lib/admin.mjs +73 -4
- package/lib/deliver.mjs +71 -8
- package/lib/gateway-core.mjs +4 -2
- package/lib/httpsig.mjs +27 -12
- package/lib/intake.mjs +19 -6
- package/lib/keys.mjs +18 -4
- package/lib/lease.mjs +10 -4
- package/lib/mastoapi.mjs +2 -2
- package/lib/proof.mjs +79 -0
- package/lib/publisher.mjs +6 -3
- package/lib/tagfeed.mjs +2 -2
- package/lib/update.mjs +7 -2
- package/lib/wire.mjs +41 -2
- package/package.json +2 -1
- package/run-agent.mjs +10 -3
- package/web/admin/admin.js +64 -5
- package/web/admin/index.html +30 -3
- package/web/front/new-account.html +11 -392
- package/web/front/new-account.html~ +50 -0
package/lib/proof.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// proof.mjs — FEP-8b32 object integrity proofs (Data Integrity, eddsa-jcs-2022).
|
|
2
|
+
//
|
|
3
|
+
// An HTTP signature covers one delivery and says nothing about who wrote what
|
|
4
|
+
// was inside it. A proof travels with the activity, so a server that receives
|
|
5
|
+
// one of ours second-hand — carried by a group, or forwarded by a follower's
|
|
6
|
+
// server — can tell it is ours without asking us.
|
|
7
|
+
//
|
|
8
|
+
// The RSA `#main-key` and its HTTP signatures are untouched; this is a second
|
|
9
|
+
// key alongside, which is what the Data Integrity suites require.
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import serialize from 'json-canon';
|
|
13
|
+
|
|
14
|
+
// multicodec: an Ed25519 public key is its 32 raw bytes behind 0xed 0x01.
|
|
15
|
+
const ED25519_PREFIX = Buffer.from([0xed, 0x01]);
|
|
16
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
17
|
+
|
|
18
|
+
export function base58btc(bytes) {
|
|
19
|
+
const b = Buffer.from(bytes);
|
|
20
|
+
let n = 0n;
|
|
21
|
+
for (const byte of b) n = (n << 8n) + BigInt(byte);
|
|
22
|
+
let out = '';
|
|
23
|
+
while (n > 0n) { out = B58[Number(n % 58n)] + out; n /= 58n; }
|
|
24
|
+
for (const byte of b) { if (byte !== 0) break; out = B58[0] + out; }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `publicKeyMultibase` form of an Ed25519 public key in PEM. */
|
|
29
|
+
export function multibaseEd25519(publicPem) {
|
|
30
|
+
const raw = Buffer.from(crypto.createPublicKey(publicPem).export({ format: 'jwk' }).x, 'base64url');
|
|
31
|
+
return 'z' + base58btc(Buffer.concat([ED25519_PREFIX, raw]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The signing key, as WebCrypto wants it. */
|
|
35
|
+
export function edPrivateKey(privatePem) {
|
|
36
|
+
const jwk = crypto.createPrivateKey(privatePem).export({ format: 'jwk' });
|
|
37
|
+
return crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'] }, { name: 'Ed25519' }, true, ['sign']);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
|
|
41
|
+
|
|
42
|
+
// The proof's own terms have to be declared in the document that carries it,
|
|
43
|
+
// or a receiver that processes JSON-LD expands the proof away before it ever
|
|
44
|
+
// looks at it. Declared BEFORE signing, because the proof covers the document
|
|
45
|
+
// exactly as sent, `@context` included.
|
|
46
|
+
export const DI_CTX = 'https://w3id.org/security/data-integrity/v1';
|
|
47
|
+
|
|
48
|
+
function withProofContext(doc) {
|
|
49
|
+
const ctx = doc['@context'];
|
|
50
|
+
const list = Array.isArray(ctx) ? ctx : ctx ? [ctx] : [];
|
|
51
|
+
if (list.includes(DI_CTX)) return doc;
|
|
52
|
+
return { ...doc, '@context': [...list, DI_CTX] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return the activity with a `proof` attached. The proof covers the activity
|
|
57
|
+
* exactly as it goes on the wire, minus the proof itself — so it must be the
|
|
58
|
+
* last thing added, and nothing may edit the activity afterwards.
|
|
59
|
+
*
|
|
60
|
+
* Whole seconds on `created`: a verifier re-serializes the timestamp it was
|
|
61
|
+
* given, and a fractional one has more than one spelling.
|
|
62
|
+
*/
|
|
63
|
+
export async function attachProof(activity, { privateKey, verificationMethod, created = new Date() } = {}) {
|
|
64
|
+
if (!privateKey || !verificationMethod || !activity || typeof activity !== 'object') return activity;
|
|
65
|
+
const { proof: _existing, ...bare } = activity;
|
|
66
|
+
const doc = withProofContext(bare);
|
|
67
|
+
const config = {
|
|
68
|
+
'@context': doc['@context'],
|
|
69
|
+
type: 'DataIntegrityProof',
|
|
70
|
+
cryptosuite: 'eddsa-jcs-2022',
|
|
71
|
+
verificationMethod,
|
|
72
|
+
proofPurpose: 'assertionMethod',
|
|
73
|
+
created: new Date(created).toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
74
|
+
};
|
|
75
|
+
const digest = Buffer.concat([sha256(serialize(config)), sha256(serialize(doc))]);
|
|
76
|
+
const sig = await crypto.subtle.sign('Ed25519', privateKey, digest);
|
|
77
|
+
const { '@context': _ctx, ...emitted } = config;
|
|
78
|
+
return { ...doc, proof: { ...emitted, proofValue: 'z' + base58btc(Buffer.from(sig)) } };
|
|
79
|
+
}
|
package/lib/publisher.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
22
22
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
|
|
23
23
|
|
|
24
24
|
export class Publisher {
|
|
25
|
-
constructor({ config, remote, local, store, deliverer, publicKeyPem, log = console.log,
|
|
25
|
+
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
26
|
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
27
27
|
}) {
|
|
28
28
|
this.config = config;
|
|
@@ -31,6 +31,7 @@ export class Publisher {
|
|
|
31
31
|
this.store = store;
|
|
32
32
|
this.deliverer = deliverer;
|
|
33
33
|
this.publicKeyPem = publicKeyPem;
|
|
34
|
+
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
34
35
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
35
36
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
36
37
|
const publicBase = config.gateway?.frontActor
|
|
@@ -76,7 +77,8 @@ export class Publisher {
|
|
|
76
77
|
const actorDoc = wire.actorDoc({
|
|
77
78
|
urls, handle: this.config.handle, name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
78
79
|
movedTo: this.config.movedTo || null, kind: this.config.kind,
|
|
79
|
-
approveJoins:
|
|
80
|
+
approveJoins: wire.followsNeedApproval(this.config),
|
|
81
|
+
assertionKey: this.assertionKey,
|
|
80
82
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
81
83
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
82
84
|
webId: this.remote.webId || null,
|
|
@@ -350,7 +352,8 @@ export class Publisher {
|
|
|
350
352
|
await this.remote.putJson(urls.actor, wire.actorDoc({
|
|
351
353
|
urls, handle: this.config.handle, name: this.config.name,
|
|
352
354
|
publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
|
|
353
|
-
approveJoins:
|
|
355
|
+
approveJoins: wire.followsNeedApproval(this.config),
|
|
356
|
+
assertionKey: this.assertionKey,
|
|
354
357
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
355
358
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
356
359
|
webId: this.remote.webId || null,
|
package/lib/tagfeed.mjs
CHANGED
|
@@ -135,10 +135,10 @@ export class TagFeed {
|
|
|
135
135
|
if (!this.store.getActors()[author]) {
|
|
136
136
|
await this.intake.fetchAP(author).catch(() => {}); // warm name+avatar
|
|
137
137
|
}
|
|
138
|
-
const { attachmentsOf,
|
|
138
|
+
const { attachmentsOf, titledContent } = await import('./wire.mjs');
|
|
139
139
|
const attachments = attachmentsOf(note);
|
|
140
140
|
this.store.addStatus({
|
|
141
|
-
noteId, actor: author, content:
|
|
141
|
+
noteId, actor: author, content: titledContent(note),
|
|
142
142
|
published: note.published, inReplyTo: note.inReplyTo, kind: 'tag', tag,
|
|
143
143
|
...(attachments.length ? { attachments } : {}),
|
|
144
144
|
});
|
package/lib/update.mjs
CHANGED
|
@@ -42,16 +42,21 @@ export async function checkLatest({ current = localVersion(), fetchImpl = fetch
|
|
|
42
42
|
} catch { return null; }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// The npm half, injectable — a test asking what runUpdate DOES must never be
|
|
46
|
+
// able to install software on the machine running it.
|
|
47
|
+
const npmInstallLatest = () =>
|
|
48
|
+
execFileSync('npm', ['install', '-g', 'fedipod@latest'], { stdio: 'pipe' });
|
|
49
|
+
|
|
45
50
|
// Fast-forward only: a checkout with local changes is somebody's work, and an
|
|
46
51
|
// update must refuse rather than eat it.
|
|
47
|
-
export function runUpdate({ root = repoRoot(), log = () => {} } = {}) {
|
|
52
|
+
export function runUpdate({ root = repoRoot(), log = () => {}, install = npmInstallLatest } = {}) {
|
|
48
53
|
// The ordinary install comes from npm, where updating is npm's job and the
|
|
49
54
|
// files under us are not ours to move. A checkout is somebody working on it,
|
|
50
55
|
// and gets the fast-forward below.
|
|
51
56
|
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
52
57
|
try {
|
|
53
58
|
log('updating from npm…');
|
|
54
|
-
|
|
59
|
+
install();
|
|
55
60
|
return { ok: true, note: 'updated from npm — restart the agent to serve it' };
|
|
56
61
|
} catch (e) {
|
|
57
62
|
return { ok: false,
|
package/lib/wire.mjs
CHANGED
|
@@ -87,7 +87,19 @@ export function jrd({ handle, host, actor }) {
|
|
|
87
87
|
|
|
88
88
|
// `kind: 'group'` publishes a Group rather than a Person, which is what makes
|
|
89
89
|
// Mastodon and Lemmy treat the actor as a community you join.
|
|
90
|
-
|
|
90
|
+
// Whether a follow will be held for the operator instead of accepted on
|
|
91
|
+
// arrival. A person's follows wait unless `autoAcceptFollows` says otherwise,
|
|
92
|
+
// because a delivery on its own proves nothing about who sent it; a group is
|
|
93
|
+
// open unless its operator asked to see joins. The actor document has to say
|
|
94
|
+
// the same thing, or a remote server shows "Following" for a follow that is
|
|
95
|
+
// in fact still waiting, and nothing ever tells it otherwise.
|
|
96
|
+
export function followsNeedApproval(config = {}) {
|
|
97
|
+
return !!config.approveJoins || (config.kind !== 'group' && !config.autoAcceptFollows);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
101
|
+
|
|
102
|
+
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
91
103
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
92
104
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
93
105
|
blocked = null, inbox = null }) {
|
|
@@ -103,6 +115,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
|
|
|
103
115
|
// inline exactly as Mastodon declares it.
|
|
104
116
|
context.push({ toot: 'http://joinmastodon.org/ns#', featured: { '@id': 'toot:featured', '@type': '@id' } });
|
|
105
117
|
if (approveJoins) context.push({ manuallyApprovesFollowers: 'as:manuallyApprovesFollowers' });
|
|
118
|
+
// FEP-8b32. The Multikey is a SECOND key beside publicKey/#main-key, not a
|
|
119
|
+
// replacement: HTTP signatures stay RSA, and only the proof is Ed25519.
|
|
120
|
+
// `assertionMethod` comes from the DID context and the key's own terms from
|
|
121
|
+
// the Multikey one; the proof's terms travel with the proof, not here.
|
|
122
|
+
if (assertionKey) context.push('https://www.w3.org/ns/did/v1',
|
|
123
|
+
'https://w3id.org/security/multikey/v1');
|
|
106
124
|
// FEP-4ccd and FEP-c648 terms, declared exactly as those FEPs declare them,
|
|
107
125
|
// and only when the collections are actually published.
|
|
108
126
|
if (pendingFollowers || pendingFollowing) {
|
|
@@ -130,6 +148,10 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
|
|
|
130
148
|
id: urls.actor,
|
|
131
149
|
type: kind === 'group' ? 'Group' : 'Person',
|
|
132
150
|
...(approveJoins ? { manuallyApprovesFollowers: true } : {}),
|
|
151
|
+
...(assertionKey ? { assertionMethod: [{
|
|
152
|
+
id: assertionKeyId(urls), type: 'Multikey', controller: urls.actor,
|
|
153
|
+
publicKeyMultibase: assertionKey,
|
|
154
|
+
}] } : {}),
|
|
133
155
|
...(movedTo ? { movedTo } : {}),
|
|
134
156
|
...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
|
|
135
157
|
preferredUsername: handle,
|
|
@@ -526,11 +548,28 @@ export function sanitizeHtml(html) {
|
|
|
526
548
|
});
|
|
527
549
|
}
|
|
528
550
|
|
|
551
|
+
// A Lemmy post, a blog article, a PeerTube video and a Bookwyrm review each
|
|
552
|
+
// carry their headline in `name` and their body in `content`. Reading content
|
|
553
|
+
// alone loses the headline, and a link post is often nothing else — so keep it
|
|
554
|
+
// as the first line, unless the body already opens with it.
|
|
555
|
+
export function titledContent(note) {
|
|
556
|
+
const html = sanitizeHtml(note?.content);
|
|
557
|
+
const types = Array.isArray(note?.type) ? note.type : [note?.type];
|
|
558
|
+
const title = typeof note?.name === 'string' ? note.name.trim() : '';
|
|
559
|
+
if (!title || types.includes('Note')) return html;
|
|
560
|
+
const opening = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
561
|
+
if (opening.startsWith(title.toLowerCase())) return html;
|
|
562
|
+
const esc = String(title).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
|
|
563
|
+
return `<p><strong>${esc}</strong></p>${html}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
529
566
|
// Normalize a wire Note's attachment list to { url, mediaType, description }.
|
|
530
567
|
export function attachmentsOf(note) {
|
|
531
568
|
const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
|
|
569
|
+
// A bare Link carries `href` and no `url` — Lemmy's link posts arrive that
|
|
570
|
+
// way, and reading only `url` dropped the link the post was about.
|
|
532
571
|
return list.map(a => ({
|
|
533
|
-
url: typeof a?.url === 'string' ? a.url : a?.url?.href,
|
|
572
|
+
url: typeof a?.url === 'string' ? a.url : a?.url?.href || (typeof a?.href === 'string' ? a.href : undefined),
|
|
534
573
|
mediaType: a?.mediaType || '',
|
|
535
574
|
...(a?.name ? { description: a.name } : {}),
|
|
536
575
|
})).filter(a => a.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@netlify/blobs": "^10.7.13",
|
|
44
44
|
"@solid/access-token-verifier": "^2.1.1",
|
|
45
45
|
"jose": "^6.2.8",
|
|
46
|
+
"json-canon": "^1.0.1",
|
|
46
47
|
"node-forge": "^1.4.0",
|
|
47
48
|
"rdflib": "^2.4.0",
|
|
48
49
|
"sanitize-html": "^2.17.6",
|
package/run-agent.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import { Lease } from './lib/lease.mjs';
|
|
|
47
47
|
import { startAdmin } from './lib/admin.mjs';
|
|
48
48
|
import { exposureProblem, hostLabel } from './lib/guard.mjs';
|
|
49
49
|
import { pendingSteps } from './lib/migrate.mjs';
|
|
50
|
-
import { apUrls } from './lib/wire.mjs';
|
|
50
|
+
import { apUrls, assertionKeyId } from './lib/wire.mjs';
|
|
51
51
|
import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
|
|
52
52
|
|
|
53
53
|
export class Agent {
|
|
@@ -324,11 +324,14 @@ export class Agent {
|
|
|
324
324
|
clearInterval(this.schedTimer);
|
|
325
325
|
this.deliverer = new Deliverer({
|
|
326
326
|
store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
|
|
327
|
+
actorId: this.urls.actor, edPrivate: keys.edPrivate,
|
|
328
|
+
proofKeyId: assertionKeyId(this.urls),
|
|
327
329
|
log: this.log, passive: this.viewer,
|
|
328
330
|
});
|
|
329
331
|
this.publisher = new Publisher({
|
|
330
332
|
config, remote: this.remote, local: this.local, store: this.store,
|
|
331
|
-
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
333
|
+
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
334
|
+
assertionKey: keys.edPublicMultibase, log: this.log,
|
|
332
335
|
resolveMention: (h) => resolveHandle(this, h),
|
|
333
336
|
// Whether the fediverse tree is on the pod at all, so the ACL check does
|
|
334
337
|
// not probe for something the default layout keeps on local disk.
|
|
@@ -355,7 +358,9 @@ export class Agent {
|
|
|
355
358
|
this.intake.bskyGroup = null;
|
|
356
359
|
if (this.viewer) {
|
|
357
360
|
this.startViewer();
|
|
358
|
-
this.log(
|
|
361
|
+
this.log(this.lease.denied === 'unreadable'
|
|
362
|
+
? `the pod cannot be read, so the lease is unknown — viewing as @${config.handle} (read-only)`
|
|
363
|
+
: `another agent is active for this pod — viewing as @${config.handle} (read-only)`);
|
|
359
364
|
return true;
|
|
360
365
|
}
|
|
361
366
|
await this.startActive({ repair });
|
|
@@ -389,7 +394,9 @@ export class Agent {
|
|
|
389
394
|
log: this.log,
|
|
390
395
|
});
|
|
391
396
|
this.publisher.publicKeyPem = keys.rsaPublicPem;
|
|
397
|
+
this.publisher.assertionKey = keys.edPublicMultibase;
|
|
392
398
|
this.deliverer.rsaPrivate = keys.rsaPrivate;
|
|
399
|
+
this.deliverer.edPrivate = keys.edPrivate;
|
|
393
400
|
await this.publisher.publishProfile();
|
|
394
401
|
return { changed: before !== keys.rsaPublicPem, publicKeyPem: keys.rsaPublicPem };
|
|
395
402
|
}
|
package/web/admin/admin.js
CHANGED
|
@@ -141,7 +141,7 @@ function render() {
|
|
|
141
141
|
['local host', (origins.named || origins.loopback || `http://localhost:${config.port}`)
|
|
142
142
|
.replace(/\/$/, '')],
|
|
143
143
|
];
|
|
144
|
-
if (config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
|
|
144
|
+
if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
|
|
145
145
|
if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
|
|
146
146
|
if (config.movedTo) rows.push(['moved to', config.movedTo]);
|
|
147
147
|
// A person gates followers here; a group's gate is the joins control on its
|
|
@@ -190,8 +190,13 @@ function render() {
|
|
|
190
190
|
dd.append(UPDATE_CTL);
|
|
191
191
|
UPDATE_CTL.hidden = false;
|
|
192
192
|
const u = config.update;
|
|
193
|
+
// The version this agent is running, never the one sitting in the
|
|
194
|
+
// checkout — saying otherwise would name a version nobody is serving.
|
|
195
|
+
const running = config.version || u?.current || null;
|
|
193
196
|
const words = [];
|
|
194
|
-
if (
|
|
197
|
+
if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
|
|
198
|
+
if (config.versionOnDisk && running && config.versionOnDisk !== running)
|
|
199
|
+
words.push(`${config.versionOnDisk} is on disk — restart to run it`);
|
|
195
200
|
if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
|
|
196
201
|
UPDATE_WORD.textContent = words.join('; ');
|
|
197
202
|
UPDATE_GO.hidden = !u?.available;
|
|
@@ -232,6 +237,7 @@ function render() {
|
|
|
232
237
|
renderAliases();
|
|
233
238
|
renderOthers();
|
|
234
239
|
renderInbox();
|
|
240
|
+
renderGateway();
|
|
235
241
|
if (config.kind === 'group') {
|
|
236
242
|
// Its lists have no bound, so this page scrolls — see body.group in the CSS.
|
|
237
243
|
document.body.classList.add('group');
|
|
@@ -931,9 +937,62 @@ async function renderInbox() {
|
|
|
931
937
|
panel.hidden = false;
|
|
932
938
|
}
|
|
933
939
|
|
|
934
|
-
// The
|
|
935
|
-
//
|
|
936
|
-
|
|
940
|
+
// The gateway panel: attach through a multi-user front with this agent's own
|
|
941
|
+
// credential, see what the door has verified, detach back to the pod inbox.
|
|
942
|
+
let gwTimer = null;
|
|
943
|
+
async function renderGateway() {
|
|
944
|
+
const { status, json: g } = await api('/gateway');
|
|
945
|
+
if (status !== 200 || !g) return; // no answer, no surface
|
|
946
|
+
$('pane-gateway').hidden = false;
|
|
947
|
+
if (g.configured) {
|
|
948
|
+
const host = (() => { try { return new URL(g.url).host; } catch { return g.url; } })();
|
|
949
|
+
const st = g.stats || {};
|
|
950
|
+
$('gateway-summary').textContent = `Attached to ${host} (mode ${g.mode})`
|
|
951
|
+
+ (g.frontActor ? `, publishing as ${g.frontActor}` : '')
|
|
952
|
+
+ ` — ${st.verified || 0} deliveries verified, ${st.unverified || 0} unverified.`;
|
|
953
|
+
$('gateway-attach-form').hidden = true;
|
|
954
|
+
$('gateway-attached').hidden = false;
|
|
955
|
+
} else {
|
|
956
|
+
$('gateway-summary').textContent = 'Mail arrives directly at your pod\'s own inbox.';
|
|
957
|
+
$('gateway-attach-form').hidden = false;
|
|
958
|
+
$('gateway-attached').hidden = true;
|
|
959
|
+
if (!$('gw-name').value && config?.handle) $('gw-name').value = config.handle;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Live availability, asked through the agent (the front answers it without CORS).
|
|
963
|
+
function gwCheck() {
|
|
964
|
+
clearTimeout(gwTimer);
|
|
965
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
966
|
+
const name = $('gw-name').value.trim().toLowerCase();
|
|
967
|
+
$('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
|
|
968
|
+
if (!front || !name) return;
|
|
969
|
+
gwTimer = setTimeout(async () => {
|
|
970
|
+
const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
|
|
971
|
+
if (status !== 200 || !json) return;
|
|
972
|
+
$('gw-name-msg').textContent = json.available
|
|
973
|
+
? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
|
|
974
|
+
: (json.reason || 'that name is taken');
|
|
975
|
+
$('gw-name-msg').className = json.available ? 'hint' : 'warn';
|
|
976
|
+
}, 300);
|
|
977
|
+
}
|
|
978
|
+
$('gw-front').addEventListener('input', gwCheck);
|
|
979
|
+
$('gw-name').addEventListener('input', gwCheck);
|
|
980
|
+
$('gw-attach').onclick = async () => {
|
|
981
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
982
|
+
const fronted = $('gw-shape').value === 'front';
|
|
983
|
+
$('gw-attach').disabled = true;
|
|
984
|
+
const r = await write('/gateway',
|
|
985
|
+
{ action: 'attach', front, handle: $('gw-name').value.trim().toLowerCase(), fronted },
|
|
986
|
+
fronted ? 'attached — restart the agent to publish under the gateway name'
|
|
987
|
+
: 'attached — your mail now arrives through the gateway, filtered');
|
|
988
|
+
$('gw-attach').disabled = false;
|
|
989
|
+
if (r) renderGateway();
|
|
990
|
+
};
|
|
991
|
+
$('gw-detach').onclick = async () => {
|
|
992
|
+
const r = await write('/gateway', { action: 'forget' },
|
|
993
|
+
'detached — the actor was republished advertising your pod\'s own inbox');
|
|
994
|
+
if (r) renderGateway();
|
|
995
|
+
};
|
|
937
996
|
|
|
938
997
|
$('inbox-keep').addEventListener('click', () => {
|
|
939
998
|
dismissed = true;
|
package/web/admin/index.html
CHANGED
|
@@ -314,9 +314,36 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
314
314
|
</span>
|
|
315
315
|
</section>
|
|
316
316
|
|
|
317
|
-
<!-- The inbox
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
<!-- The inbox gateway: a mail filter for an account that stays yours. -->
|
|
318
|
+
<section id="pane-gateway" hidden>
|
|
319
|
+
<h2><span class="hlabel">Gateway</span></h2>
|
|
320
|
+
<p id="gateway-summary"></p>
|
|
321
|
+
<div id="gateway-attach-form" hidden>
|
|
322
|
+
<p class="hint">A gateway filters your fediverse mail before it reaches your pod: your
|
|
323
|
+
actor advertises the gateway's door as its inbox, each delivery is verified there,
|
|
324
|
+
spam is dropped, and the rest lands in your pod as before. Attaching proves the pod
|
|
325
|
+
with this agent's own credential — no password leaves this machine.</p>
|
|
326
|
+
<p>
|
|
327
|
+
<label for="gw-front">Gateway</label>
|
|
328
|
+
<input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
|
|
329
|
+
<label for="gw-name">Your name there</label>
|
|
330
|
+
<input type="text" id="gw-name" autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
331
|
+
</p>
|
|
332
|
+
<p id="gw-name-msg" class="hint"></p>
|
|
333
|
+
<p>
|
|
334
|
+
<select id="gw-shape" aria-label="Name shape" title=" Which address the fediverse sees — the pod-based name keeps everything on your pod; a gateway-based name survives changing pods">
|
|
335
|
+
<option value="pod">keep my pod-based name</option>
|
|
336
|
+
<option value="front">use a gateway-based name</option>
|
|
337
|
+
</select>
|
|
338
|
+
<button id="gw-attach" class="primary" title=" Create the gateway account and point your mail through its door">Attach</button>
|
|
339
|
+
</p>
|
|
340
|
+
</div>
|
|
341
|
+
<div id="gateway-attached" hidden>
|
|
342
|
+
<p>
|
|
343
|
+
<button id="gw-detach" class="inline danger" title=" Republish the actor with your pod's own inbox and forget the gateway">Detach</button>
|
|
344
|
+
</p>
|
|
345
|
+
</div>
|
|
346
|
+
</section>
|
|
320
347
|
|
|
321
348
|
<section id="pane-group" hidden>
|
|
322
349
|
<!-- Both queues are consequences of a moderation setting: with the setting off
|