fedipod 0.9.0 → 0.11.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 +19 -1
- package/lib/admin.mjs +134 -7
- package/lib/lease.mjs +10 -4
- package/lib/mastoapi.mjs +3 -3
- package/lib/publisher.mjs +14 -8
- package/lib/wire.mjs +6 -0
- package/package.json +1 -1
- package/run-agent.mjs +7 -3
- package/web/admin/admin.js +102 -5
- package/web/admin/index.html +50 -6
- package/web/front/new-account.html +11 -392
- package/web/front/new-account.html~ +50 -0
package/bin/fedipod.mjs
CHANGED
|
@@ -1492,12 +1492,44 @@ if (cmd === 'up') {
|
|
|
1492
1492
|
console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
|
|
1493
1493
|
} else if (cmd === 'gateway' || cmd === 'front') {
|
|
1494
1494
|
// Attach this identity to a gateway ('front' kept as an alias). Shapes:
|
|
1495
|
-
// fedipod gateway
|
|
1495
|
+
// fedipod gateway --attach <origin> [--name N] [--fronted] ask the gateway
|
|
1496
|
+
// itself: the agent proves the pod with its own credential and stores
|
|
1497
|
+
// the door + secret the gateway answers with.
|
|
1498
|
+
// fedipod gateway <.../ap/inbox/> --secret S --inbox-only paste-in form:
|
|
1496
1499
|
// identity stays on the pod; only the advertised inbox moves to the
|
|
1497
1500
|
// gateway's door. Leaving is one republish with the pod inbox.
|
|
1498
1501
|
// fedipod gateway <.../ap/actor> --secret S fronted identity
|
|
1499
1502
|
// fedipod gateway --detach back to the pod inbox
|
|
1500
1503
|
requireIdentity();
|
|
1504
|
+
if (has('attach')) {
|
|
1505
|
+
const front = flag('attach');
|
|
1506
|
+
if (!/^https?:\/\/\S+$/.test(String(front || ''))) {
|
|
1507
|
+
console.error('usage: fedipod gateway --attach <https://gateway-origin> [--name yourname] [--fronted]');
|
|
1508
|
+
process.exit(2);
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
const res = await localFetch(HOME, PORT, `/gateway`, {
|
|
1512
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1513
|
+
body: JSON.stringify({ action: 'attach', front,
|
|
1514
|
+
...(flag('name') ? { handle: flag('name') } : {}),
|
|
1515
|
+
...(has('fronted') ? { fronted: true } : {}) }),
|
|
1516
|
+
});
|
|
1517
|
+
const body = await res.json();
|
|
1518
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1519
|
+
if (body.frontActor) {
|
|
1520
|
+
console.log(`attached — this identity now publishes as ${body.address || body.frontActor}`);
|
|
1521
|
+
console.log('The agent is restarting itself to publish under the front.');
|
|
1522
|
+
} else {
|
|
1523
|
+
console.log(`attached — your mail now arrives through ${body.url}, filtered; your name has not moved.`);
|
|
1524
|
+
console.log('Starting in shadow: the door filters, and the agent measures how much');
|
|
1525
|
+
console.log('verifies before it believes any receipt. Move to trust when you are ready.');
|
|
1526
|
+
}
|
|
1527
|
+
} catch (e) {
|
|
1528
|
+
console.error(`agent not reachable on :${PORT} (${e.message}) — start it, then attach`);
|
|
1529
|
+
process.exit(1);
|
|
1530
|
+
}
|
|
1531
|
+
process.exit(0);
|
|
1532
|
+
}
|
|
1501
1533
|
if (has('detach')) {
|
|
1502
1534
|
try {
|
|
1503
1535
|
const res = await localFetch(HOME, PORT, `/gateway`, {
|
package/cli.md
CHANGED
|
@@ -38,9 +38,10 @@ several identities, say which you mean. `profiles` does not list identities
|
|
|
38
38
|
under a custom `AP_HOME`.
|
|
39
39
|
|
|
40
40
|
Commands that manage a live identity — `alias`, `admit`, `import`, `archive`,
|
|
41
|
-
`bsky`, `describe`, `status`, `rebuild`, `gateway --
|
|
42
|
-
commands — talk to the running agent and say
|
|
43
|
-
`state`, `home` and
|
|
41
|
+
`bsky`, `describe`, `status`, `rebuild`, `gateway --attach`,
|
|
42
|
+
`gateway --detach` and the group commands — talk to the running agent and say
|
|
43
|
+
so when it is not there. `keys`, `state`, `home` and the paste-in gateway form
|
|
44
|
+
(`gateway <url> --secret …`) want the agent stopped.
|
|
44
45
|
|
|
45
46
|
## Recovery
|
|
46
47
|
|
|
@@ -189,9 +190,16 @@ are not imported.
|
|
|
189
190
|
## The gateway
|
|
190
191
|
|
|
191
192
|
```
|
|
193
|
+
fedipod gateway --attach <gateway-origin> [--name N] [--fronted]
|
|
192
194
|
fedipod gateway <door-inbox-url> --secret <hmac> --inbox-only
|
|
193
195
|
fedipod gateway --detach
|
|
194
196
|
```
|
|
197
|
+
|
|
198
|
+
`--attach` asks the gateway itself: the agent proves the pod with its own
|
|
199
|
+
credential, and the gateway answers with the door and the receipt secret.
|
|
200
|
+
`--name` defaults to your handle; `--fronted` takes a gateway-based name,
|
|
201
|
+
and the agent restarts itself to publish under it. The admin page's Gateway
|
|
202
|
+
row offers the same action with the name checked as you type.
|
|
195
203
|
Attaching points your actor's advertised inbox at a gateway's door, so
|
|
196
204
|
deliveries are verified and de-junked before they reach your pod; your name,
|
|
197
205
|
key and data stay on your pod. The URL and secret come from the gateway's
|
package/gui.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
|
|
4
4
|
Picking an actor marked "(stopped)" starts its agent, then opens its page.
|
|
5
5
|
|
|
6
|
+
The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
|
|
7
|
+
|
|
6
8
|
`Update`, shown on the software row when a newer FediPod exists, pulls the latest version and restarts every agent on the machine — agents installed as services; any started by hand need their own restart. The same row flags an older data layout after an update; the `fedipod upgrade` terminal command lists those moves and the commands that make them.
|
|
7
9
|
|
|
8
10
|
* **Parking** (the status control) stops the mail and unfollows people you follow, but keeps your handle
|
|
@@ -70,12 +72,28 @@ follows the group's own moderation settings; see [Groups](groups.md).
|
|
|
70
72
|
**new followers** control switches between waiting for your approval and
|
|
71
73
|
accepting automatically.
|
|
72
74
|
|
|
75
|
+
## The gateway
|
|
76
|
+
|
|
77
|
+
The **gateway** row on the record (under **software**) attaches this account
|
|
78
|
+
to a mail-filtering gateway (a fedipod.net-style front) and detaches it; when
|
|
79
|
+
attached it names the gateway, and the handle there when you took one. Both
|
|
80
|
+
buttons open a popup. Attaching offers the choice of handle: keep your
|
|
81
|
+
pod-based handle (`@you@your.pod` — you can drop the gateway any time and
|
|
82
|
+
keep everything), or create a handle at the gateway (`@you@the-gateway` —
|
|
83
|
+
your address lives on the gateway's domain, but not your data or key), typed
|
|
84
|
+
straight into the blank of the address with a free/taken check as you type.
|
|
85
|
+
The gateway account is created automatically with either choice, and the
|
|
86
|
+
agent proves the pod with its own credential, so no password is typed
|
|
87
|
+
anywhere. Taking or leaving a gateway handle restarts the agent by itself;
|
|
88
|
+
detaching republishes your actor with your pod's own inbox.
|
|
89
|
+
|
|
73
90
|
## Moving here from another server
|
|
74
91
|
|
|
75
92
|
Open **Transfer an account here** on the action rail and add your old account
|
|
76
93
|
as an alias, set
|
|
77
94
|
**new followers** to *accepted automatically*, then trigger the move on the
|
|
78
|
-
old server
|
|
95
|
+
old server (on Mastodon: Preferences → Account → *Move to a different
|
|
96
|
+
account*); your followers arrive by themselves. Removing an alias asks
|
|
79
97
|
twice — servers still processing the move check it while they retry. The CSV
|
|
80
98
|
files from the old server's export are imported with the CLI; see
|
|
81
99
|
[CLI admin](cli.md).
|
package/lib/admin.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import os from 'node:os';
|
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement,
|
|
18
18
|
admitRequest, refuseRequest, resolveHandle, applyModeration, announceModeration } from './social.mjs';
|
|
19
|
-
import { addRemoveActivity, webfingerHost } from './wire.mjs';
|
|
19
|
+
import { addRemoveActivity, webfingerHost , publicHandle } from './wire.mjs';
|
|
20
20
|
import { MastoApi, hashPassword } from './mastoapi.mjs';
|
|
21
21
|
import { C2S } from './c2s.mjs';
|
|
22
22
|
import { makeC2sAuth } from './oidc-auth.mjs';
|
|
@@ -32,6 +32,7 @@ import { portFree, freePortFrom } from './ports.mjs';
|
|
|
32
32
|
import { claimDirectory, yieldDirectory } from './directory.mjs';
|
|
33
33
|
import { localFetch } from './localapi.mjs';
|
|
34
34
|
import { ensureTrustedTls } from './certs.mjs';
|
|
35
|
+
import { localVersion } from './update.mjs';
|
|
35
36
|
|
|
36
37
|
const require = createRequire(import.meta.url);
|
|
37
38
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -325,7 +326,8 @@ function sendFile(res, baseDir, rel, auth) {
|
|
|
325
326
|
// guards the operator's door (basePath) instead of the whole surface.
|
|
326
327
|
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
327
328
|
port = null, handle = null, embedded = false, basePath = '/',
|
|
328
|
-
publicOrigin = null, scheme = null
|
|
329
|
+
publicOrigin = null, scheme = null,
|
|
330
|
+
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
329
331
|
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
330
332
|
const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
|
|
331
333
|
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
@@ -488,7 +490,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
488
490
|
// the handle, and the pod host its actor URL sits on.
|
|
489
491
|
let address = null;
|
|
490
492
|
if (live?.handle && live?.actor) {
|
|
491
|
-
try {
|
|
493
|
+
try {
|
|
494
|
+
const front = live.actor.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
|
|
495
|
+
address = `${front || live.handle}@${new URL(live.actor).host}`;
|
|
496
|
+
} catch { /* not a URL yet */ }
|
|
492
497
|
}
|
|
493
498
|
return {
|
|
494
499
|
name, port, current,
|
|
@@ -566,6 +571,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
566
571
|
if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
|
|
567
572
|
const urls = agent.urls || agent.publisher?.urls || null;
|
|
568
573
|
const wfHost = urls ? new URL(urls.base).host : null;
|
|
574
|
+
// A fronted identity's address is its name AT THE FRONT — the front
|
|
575
|
+
// serves the actor under that name, whatever this pod calls it.
|
|
576
|
+
const address = cfg.gateway?.frontActor
|
|
577
|
+
? `@${publicHandle(cfg)}@${new URL(cfg.gateway.frontActor).host}`
|
|
578
|
+
: (wfHost ? `@${cfg.handle}@${wfHost}` : null);
|
|
569
579
|
return json(res, 200, {
|
|
570
580
|
// permanent
|
|
571
581
|
handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
|
|
@@ -576,7 +586,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
576
586
|
// same way every other id is — computed here rather than in the page,
|
|
577
587
|
// which has no business knowing how they are made.
|
|
578
588
|
accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
|
|
579
|
-
address
|
|
589
|
+
address,
|
|
580
590
|
// editable
|
|
581
591
|
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
582
592
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
@@ -592,6 +602,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
592
602
|
privateRoot: agent.readCredential?.()?.privateRoot || null,
|
|
593
603
|
mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
|
|
594
604
|
update: agent.updateInfo || null,
|
|
605
|
+
// What this process is running, against what a restart would run:
|
|
606
|
+
// the checkout can move while an agent stays up.
|
|
607
|
+
version: AGENT_VERSION,
|
|
608
|
+
versionOnDisk: versionOnDisk(),
|
|
595
609
|
pendingUpgrade: agent.pendingUpgrade || [],
|
|
596
610
|
// The connected Bluesky account, non-secret half. `connected` is the
|
|
597
611
|
// credential's word, so a config entry orphaned by a deleted
|
|
@@ -1423,12 +1437,47 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1423
1437
|
const cfg = { ...agent.store.getConfig() };
|
|
1424
1438
|
const g = { ...(cfg.gateway || {}) };
|
|
1425
1439
|
const inboxUrl = agent.publisher?.urls?.inbox;
|
|
1440
|
+
// Availability is a read: answered before the lease takeover below,
|
|
1441
|
+
// which every real gateway change does want.
|
|
1442
|
+
if (body.action === 'check') {
|
|
1443
|
+
const front = String(body.front || '').replace(/\/+$/, '');
|
|
1444
|
+
try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
|
|
1445
|
+
const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
|
|
1446
|
+
{ headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
|
|
1447
|
+
if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
|
|
1448
|
+
return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
|
|
1449
|
+
}
|
|
1426
1450
|
await agent.requestTakeover?.();
|
|
1427
1451
|
const persist = async () => {
|
|
1428
1452
|
cfg.gateway = g; agent.store.setConfig(cfg);
|
|
1429
1453
|
if (agent.publisher) agent.publisher.config.gateway = g;
|
|
1430
1454
|
await agent.store.flush();
|
|
1431
1455
|
};
|
|
1456
|
+
// Fronting renames the actor's ids; the signing key is the same
|
|
1457
|
+
// identity's and moves with it, wherever the key record lives.
|
|
1458
|
+
const restampKeys = async (actorId) => {
|
|
1459
|
+
try {
|
|
1460
|
+
const kp = path.join(agent.home, 'keys.json');
|
|
1461
|
+
const rec = JSON.parse(fs.readFileSync(kp, 'utf8'));
|
|
1462
|
+
if (rec.mintedFor) { rec.mintedFor = actorId; fs.writeFileSync(kp, JSON.stringify(rec)); return; }
|
|
1463
|
+
} catch { /* not local — try pod state */ }
|
|
1464
|
+
const podRec = agent.store.read('keys.json', null);
|
|
1465
|
+
if (podRec?.mintedFor) { podRec.mintedFor = actorId; agent.store.write('keys.json', podRec); await agent.store.flush(); }
|
|
1466
|
+
};
|
|
1467
|
+
const podActorId = () => {
|
|
1468
|
+
const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
|
|
1469
|
+
const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
|
|
1470
|
+
return `${base}${root}ap/actor`;
|
|
1471
|
+
};
|
|
1472
|
+
// The reply first, the restart a beat later — same shape as /update.
|
|
1473
|
+
const selfRestart = (why) => setTimeout(() => {
|
|
1474
|
+
log(why);
|
|
1475
|
+
if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
|
|
1476
|
+
const child = spawn(process.execPath, process.argv.slice(1),
|
|
1477
|
+
{ detached: true, stdio: 'ignore', env: process.env });
|
|
1478
|
+
child.unref();
|
|
1479
|
+
setTimeout(() => process.exit(0), 300);
|
|
1480
|
+
}, 200);
|
|
1432
1481
|
if (body.action === 'configure') {
|
|
1433
1482
|
if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
|
|
1434
1483
|
if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
|
|
@@ -1478,16 +1527,91 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1478
1527
|
else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
|
|
1479
1528
|
return json(res, 200, { ok: true, mode: g.mode });
|
|
1480
1529
|
}
|
|
1530
|
+
// Attach through a multi-user front (fedipod.net and kin): the agent
|
|
1531
|
+
// proves the pod with its own credential — no browser, no password —
|
|
1532
|
+
// and the front answers with the door and the receipt secret.
|
|
1533
|
+
if (body.action === 'attach') {
|
|
1534
|
+
const front = String(body.front || '').replace(/\/+$/, '');
|
|
1535
|
+
let fu;
|
|
1536
|
+
try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
|
|
1537
|
+
if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
|
|
1538
|
+
return json(res, 400, { error: 'front must be https' });
|
|
1539
|
+
}
|
|
1540
|
+
if (agent.embedded) {
|
|
1541
|
+
return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
|
|
1542
|
+
}
|
|
1543
|
+
const named = !!String(body.handle || '').trim();
|
|
1544
|
+
let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
|
|
1545
|
+
if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
|
|
1546
|
+
const fronted = body.fronted === true;
|
|
1547
|
+
// Availability first, for a clean answer before anything is created.
|
|
1548
|
+
const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
|
|
1549
|
+
{ headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
|
|
1550
|
+
let chk = await avail(handle);
|
|
1551
|
+
if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
|
|
1552
|
+
// An unnamed door name is plumbing nobody reads: walk to a free
|
|
1553
|
+
// variant instead of failing over a label the user never chose.
|
|
1554
|
+
if (!chk.available && !named) {
|
|
1555
|
+
for (let i = 2; i <= 9 && !chk.available; i++) {
|
|
1556
|
+
const cand = `${handle}${i}`;
|
|
1557
|
+
const c = await avail(cand);
|
|
1558
|
+
if (c?.available) { handle = cand; chk = c; }
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
|
|
1562
|
+
const frontActor = `${front}/u/${handle}/ap/actor`;
|
|
1563
|
+
if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
|
|
1564
|
+
return json(res, 400, { error: `this identity already fronts through ${g.frontActor} — changing a published front renames every id; detach first if you mean it` });
|
|
1565
|
+
}
|
|
1566
|
+
const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
|
|
1567
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1568
|
+
body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
|
|
1569
|
+
}).catch(() => null);
|
|
1570
|
+
if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
|
|
1571
|
+
const d = await attach.json().catch(() => ({}));
|
|
1572
|
+
if (attach.status !== 201) {
|
|
1573
|
+
return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
|
|
1574
|
+
{ error: d.error || `attach failed (HTTP ${attach.status})` });
|
|
1575
|
+
}
|
|
1576
|
+
g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
|
|
1577
|
+
if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
|
|
1578
|
+
if (fronted) g.frontActor = String(d.frontActor || frontActor);
|
|
1579
|
+
if (!g.mode || g.mode === 'off') g.mode = 'shadow';
|
|
1580
|
+
await persist();
|
|
1581
|
+
// Inbox-only applies live: the actor republishes advertising the
|
|
1582
|
+
// door. A front carries new ids, which are wired at startup — so a
|
|
1583
|
+
// fronted attach restarts this agent itself, the reply going out
|
|
1584
|
+
// first so it is not taken down with the process.
|
|
1585
|
+
if (!fronted) {
|
|
1586
|
+
await agent.publisher?.publishProfile();
|
|
1587
|
+
await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
|
|
1588
|
+
return json(res, 200, { ok: true, mode: g.mode, url: g.url });
|
|
1589
|
+
}
|
|
1590
|
+
await restampKeys(g.frontActor);
|
|
1591
|
+
json(res, 200, { ok: true, mode: g.mode, url: g.url,
|
|
1592
|
+
frontActor: g.frontActor, address: d.address || null, restarting: true });
|
|
1593
|
+
selfRestart('restarting to publish under the front');
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1481
1596
|
if (body.action === 'forget') {
|
|
1482
1597
|
const wasLocked = g.mode === 'locked';
|
|
1598
|
+
const wasFronted = !!g.frontActor;
|
|
1483
1599
|
delete cfg.gateway; agent.store.setConfig(cfg);
|
|
1484
1600
|
if (agent.publisher) agent.publisher.config.gateway = undefined;
|
|
1485
1601
|
await agent.store.flush();
|
|
1486
1602
|
if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
|
|
1603
|
+
if (wasFronted) {
|
|
1604
|
+
// Going home renames every id back to the pod: key and process
|
|
1605
|
+
// follow, the same way attach came.
|
|
1606
|
+
await restampKeys(podActorId());
|
|
1607
|
+
json(res, 200, { ok: true, mode: 'off', forgotten: true, restarting: true });
|
|
1608
|
+
selfRestart('restarting under the pod\'s own ids');
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1487
1611
|
await agent.publisher?.publishProfile(); // re-advertise the pod inbox
|
|
1488
1612
|
return json(res, 200, { ok: true, mode: 'off', forgotten: true });
|
|
1489
1613
|
}
|
|
1490
|
-
return json(res, 400, { error: 'action must be configure, mode or forget' });
|
|
1614
|
+
return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
|
|
1491
1615
|
}
|
|
1492
1616
|
// Symmetrical with /block, and open for the same reason: a block made
|
|
1493
1617
|
// by mistake is worth undoing before federation is even configured.
|
|
@@ -1534,14 +1658,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1534
1658
|
return { handler, masto, c2s, streaming };
|
|
1535
1659
|
}
|
|
1536
1660
|
|
|
1537
|
-
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null
|
|
1661
|
+
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
|
|
1662
|
+
// Injectable, so a test can put a checkout ahead of the running process
|
|
1663
|
+
// without editing the package.json of the machine running the test.
|
|
1664
|
+
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
1538
1665
|
const gate = makeGate(gateToken);
|
|
1539
1666
|
// Live, so the named origin appears the moment connect() reads the handle
|
|
1540
1667
|
// out of pod state — including for the OAuth redirect check in MastoApi.
|
|
1541
1668
|
// The https listener's port joins the authority set: same names, second port.
|
|
1542
1669
|
const allowed = new Authorities(port, handle);
|
|
1543
1670
|
agent.authorities = allowed;
|
|
1544
|
-
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
|
|
1671
|
+
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
|
|
1545
1672
|
|
|
1546
1673
|
// Loopback both ways: the canonical URL is https://localhost:<port>/, and
|
|
1547
1674
|
// "localhost" resolves to ::1 on many systems before falling back to IPv4 —
|
package/lib/lease.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export class Lease {
|
|
|
29
29
|
this.stopped = false;
|
|
30
30
|
this.timer = null;
|
|
31
31
|
this.heldUntil = 0; // wall-clock end of the lease we last wrote
|
|
32
|
+
this.denied = null; // why the last acquire() said no: 'unreadable' | 'held'
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// null means the lease document is NOT THERE — nobody holds it. UNREADABLE
|
|
@@ -74,16 +75,21 @@ export class Lease {
|
|
|
74
75
|
const cur = await this.readFresh();
|
|
75
76
|
// Cannot read it: stay a viewer. A viewer that should have been active is
|
|
76
77
|
// an inconvenience; two agents both draining is the destructive one.
|
|
77
|
-
if (cur === UNREADABLE) {
|
|
78
|
-
|
|
78
|
+
if (cur === UNREADABLE) {
|
|
79
|
+
this.denied = 'unreadable';
|
|
80
|
+
this.log('lease unreadable — staying a viewer'); return false;
|
|
81
|
+
}
|
|
82
|
+
if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) { this.denied = 'held'; return false; }
|
|
79
83
|
const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
|
|
80
|
-
if (!await this.write(doc)) return false;
|
|
84
|
+
if (!await this.write(doc)) { this.denied = 'held'; return false; }
|
|
81
85
|
const confirm = await this.readFresh();
|
|
82
|
-
if (confirm === UNREADABLE
|
|
86
|
+
if (confirm === UNREADABLE) { this.denied = 'unreadable'; return false; }
|
|
87
|
+
if (confirm?.holder !== this.id) { this.denied = 'held'; return false; }
|
|
83
88
|
// From the document we WROTE, and only once the confirm agrees. Recomputing
|
|
84
89
|
// it here would claim a lease that outlives what the pod's copy grants, by
|
|
85
90
|
// however long the PUT and the confirming GET took.
|
|
86
91
|
this.heldUntil = doc.expiresAt;
|
|
92
|
+
this.denied = null;
|
|
87
93
|
return true;
|
|
88
94
|
}
|
|
89
95
|
|
package/lib/mastoapi.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
14
|
import * as social from './social.mjs';
|
|
15
|
-
import { sanitizeHtml, followsNeedApproval } from './wire.mjs';
|
|
15
|
+
import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
|
|
16
16
|
import { authorOf } from './intake.mjs';
|
|
17
17
|
import { profileUrl, postUrl } from './bskyfeed.mjs';
|
|
18
18
|
import { Push } from './webpush.mjs';
|
|
@@ -201,7 +201,7 @@ export class MastoApi {
|
|
|
201
201
|
// No fallback handle. `account()` already reads config for self, so a
|
|
202
202
|
// literal here only ever supplies a name that is not this actor's — and it
|
|
203
203
|
// was a real person's, so every install with no handle called itself jeff.
|
|
204
|
-
return this.account(this.urls.actor, { selfAcct: this.store.getConfig()
|
|
204
|
+
return this.account(this.urls.actor, { selfAcct: publicHandle(this.store.getConfig()) });
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
account(actorUrl, { selfAcct } = {}) {
|
|
@@ -209,7 +209,7 @@ export class MastoApi {
|
|
|
209
209
|
let host = '', user = cached.preferredUsername || '';
|
|
210
210
|
try { host = new URL(actorUrl).host; if (!user) user = new URL(actorUrl).pathname.split('/').pop(); } catch {}
|
|
211
211
|
const self = actorUrl === this.urls?.actor;
|
|
212
|
-
if (self) user = selfAcct || this.store.getConfig()
|
|
212
|
+
if (self) user = selfAcct || publicHandle(this.store.getConfig()) || user;
|
|
213
213
|
return {
|
|
214
214
|
id: this.store.idFor(actorUrl),
|
|
215
215
|
username: user,
|
package/lib/publisher.mjs
CHANGED
|
@@ -75,7 +75,7 @@ export class Publisher {
|
|
|
75
75
|
const gw = this.config.gateway;
|
|
76
76
|
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
77
77
|
const actorDoc = wire.actorDoc({
|
|
78
|
-
urls, handle: this.config
|
|
78
|
+
urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
79
79
|
movedTo: this.config.movedTo || null, kind: this.config.kind,
|
|
80
80
|
approveJoins: wire.followsNeedApproval(this.config),
|
|
81
81
|
assertionKey: this.assertionKey,
|
|
@@ -173,7 +173,7 @@ export class Publisher {
|
|
|
173
173
|
await this.remote.setAcl(urls.notes, ['Read']);
|
|
174
174
|
|
|
175
175
|
await this.publishCollections({ ...ALL_COLLECTIONS, force });
|
|
176
|
-
const updated = await this.announceProfileChange(actor);
|
|
176
|
+
const updated = await this.announceProfileChange(actor, { force });
|
|
177
177
|
await this.local.writeSettings({ handle: this.config.handle, actorUrl: urls.actor });
|
|
178
178
|
await this.ensurePrivateAcls();
|
|
179
179
|
const unreachable = await this.verifyPublicSurface();
|
|
@@ -188,9 +188,13 @@ export class Publisher {
|
|
|
188
188
|
this.store.write('published.json',
|
|
189
189
|
{ ...this.store.read('published.json', {}), surfaceDigest: surface });
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
// Named as the fediverse sees it: a fronted actor's name and host are the
|
|
192
|
+
// front's, not the pod's.
|
|
193
|
+
const pubName = wire.publicHandle(this.config);
|
|
194
|
+
const pubHost = this.config.gateway?.frontActor ? new URL(this.config.gateway.frontActor).host : host;
|
|
195
|
+
this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
|
|
196
|
+
? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
|
|
197
|
+
: `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
|
|
194
198
|
+ 'this pod is a path on a shared host, and WebFinger is only answered at a host root');
|
|
195
199
|
return { unreachable, updated };
|
|
196
200
|
}
|
|
@@ -208,10 +212,12 @@ export class Publisher {
|
|
|
208
212
|
// A silent first publish was considered and is WRONG here: it would spend a
|
|
209
213
|
// real edit doing nothing but recording a digest, and that edit is exactly the
|
|
210
214
|
// one whose invisibility this fixes.
|
|
211
|
-
async announceProfileChange(actor) {
|
|
215
|
+
async announceProfileChange(actor, { force = false } = {}) {
|
|
212
216
|
const digest = crypto.createHash('sha256').update(JSON.stringify(actor)).digest('hex').slice(0, 32);
|
|
213
217
|
const seen = this.store.read('published.json', {});
|
|
214
|
-
|
|
218
|
+
// A forced republish is the operator saying TELL THE WORLD — the digest
|
|
219
|
+
// gate is for silent no-op saves, not for that.
|
|
220
|
+
if (seen.actorDigest === digest && !force) return 0;
|
|
215
221
|
this.store.write('published.json', { ...seen, actorDigest: digest, at: new Date().toISOString() });
|
|
216
222
|
const inboxes = [...new Set(this.store.getContacts().followers
|
|
217
223
|
.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
@@ -350,7 +356,7 @@ export class Publisher {
|
|
|
350
356
|
this.config.movedTo = target; // so the republish below carries it
|
|
351
357
|
this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
|
|
352
358
|
await this.remote.putJson(urls.actor, wire.actorDoc({
|
|
353
|
-
urls, handle: this.config
|
|
359
|
+
urls, handle: wire.publicHandle(this.config), name: this.config.name,
|
|
354
360
|
publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
|
|
355
361
|
approveJoins: wire.followsNeedApproval(this.config),
|
|
356
362
|
assertionKey: this.assertionKey,
|
package/lib/wire.mjs
CHANGED
|
@@ -21,6 +21,12 @@ export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
|
21
21
|
// passing advertised ids and the writes still land on the pod. With no
|
|
22
22
|
// `publicBase` the returned object is byte-identical to before — the invariant
|
|
23
23
|
// the smoke suite pins.
|
|
24
|
+
// The handle the fediverse sees: a fronted identity's name is the front's.
|
|
25
|
+
export function publicHandle(config) {
|
|
26
|
+
return config?.gateway?.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1]
|
|
27
|
+
|| config?.handle || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
export function apUrls(remotePod, root = 'activitypods-js/', { publicBase = null } = {}) {
|
|
25
31
|
const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
|
|
26
32
|
const home = base + (!root || root.endsWith('/') ? root : root + '/');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/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, assertionKeyId } from './lib/wire.mjs';
|
|
50
|
+
import { apUrls, assertionKeyId , publicHandle } from './lib/wire.mjs';
|
|
51
51
|
import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
|
|
52
52
|
|
|
53
53
|
export class Agent {
|
|
@@ -358,7 +358,9 @@ export class Agent {
|
|
|
358
358
|
this.intake.bskyGroup = null;
|
|
359
359
|
if (this.viewer) {
|
|
360
360
|
this.startViewer();
|
|
361
|
-
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)`);
|
|
362
364
|
return true;
|
|
363
365
|
}
|
|
364
366
|
await this.startActive({ repair });
|
|
@@ -537,7 +539,9 @@ export class Agent {
|
|
|
537
539
|
this.schedTimer.unref();
|
|
538
540
|
// A CSV import interrupted by a restart or a handoff picks back up here.
|
|
539
541
|
this.importer?.resume();
|
|
540
|
-
|
|
542
|
+
// The identity the fediverse actually sees: a fronted actor's name and
|
|
543
|
+
// host are the front's, not the pod's.
|
|
544
|
+
this.log(`federating as @${publicHandle(this.store.getConfig())}@${new URL(this.urls.actor).host}`);
|
|
541
545
|
if (this.renamed) {
|
|
542
546
|
// The display name lives in the actor document, so a rename only
|
|
543
547
|
// reaches other servers once that is republished.
|
package/web/admin/admin.js
CHANGED
|
@@ -141,7 +141,8 @@ 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
|
+
rows.push(['gateway', 'ctl']);
|
|
145
146
|
if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
|
|
146
147
|
if (config.movedTo) rows.push(['moved to', config.movedTo]);
|
|
147
148
|
// A person gates followers here; a group's gate is the joins control on its
|
|
@@ -185,13 +186,23 @@ function render() {
|
|
|
185
186
|
FOLLOWS_CTL.hidden = false;
|
|
186
187
|
FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
|
|
187
188
|
}
|
|
189
|
+
if (k === 'gateway') {
|
|
190
|
+
dd.textContent = '';
|
|
191
|
+
dd.append(GATEWAY_CTL);
|
|
192
|
+
refreshGateway();
|
|
193
|
+
}
|
|
188
194
|
if (k === 'software') {
|
|
189
195
|
dd.textContent = '';
|
|
190
196
|
dd.append(UPDATE_CTL);
|
|
191
197
|
UPDATE_CTL.hidden = false;
|
|
192
198
|
const u = config.update;
|
|
199
|
+
// The version this agent is running, never the one sitting in the
|
|
200
|
+
// checkout — saying otherwise would name a version nobody is serving.
|
|
201
|
+
const running = config.version || u?.current || null;
|
|
193
202
|
const words = [];
|
|
194
|
-
if (
|
|
203
|
+
if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
|
|
204
|
+
if (config.versionOnDisk && running && config.versionOnDisk !== running)
|
|
205
|
+
words.push(`${config.versionOnDisk} is on disk — restart to run it`);
|
|
195
206
|
if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
|
|
196
207
|
UPDATE_WORD.textContent = words.join('; ');
|
|
197
208
|
UPDATE_GO.hidden = !u?.available;
|
|
@@ -931,9 +942,95 @@ async function renderInbox() {
|
|
|
931
942
|
panel.hidden = false;
|
|
932
943
|
}
|
|
933
944
|
|
|
934
|
-
// The
|
|
935
|
-
//
|
|
936
|
-
//
|
|
945
|
+
// The gateway, as a facts row under software: attach through a multi-user
|
|
946
|
+
// front with this agent's own credential, detach back to the pod inbox. The
|
|
947
|
+
// forms live in the floating window, like every other disclosure.
|
|
948
|
+
const GATEWAY_CTL = $('gateway-ctl'); // held: it rides into a generated row
|
|
949
|
+
const GATEWAY_WORD = $('gateway-word');
|
|
950
|
+
const GW_OPEN_ATTACH = $('gateway-open-attach');
|
|
951
|
+
const GW_OPEN_DETACH = $('gateway-open-detach');
|
|
952
|
+
let gwState = null;
|
|
953
|
+
async function refreshGateway() {
|
|
954
|
+
const { status, json: g } = await api('/gateway');
|
|
955
|
+
if (status !== 200 || !g) return;
|
|
956
|
+
gwState = g;
|
|
957
|
+
GATEWAY_CTL.hidden = false;
|
|
958
|
+
if (g.configured) {
|
|
959
|
+
let host = g.url;
|
|
960
|
+
try { host = new URL(g.url).host; } catch { /* show it as-is */ }
|
|
961
|
+
const frontName = g.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
|
|
962
|
+
GATEWAY_WORD.textContent = frontName ? `${host} — publishing as @${frontName}@${host}` : host;
|
|
963
|
+
GW_OPEN_ATTACH.hidden = true;
|
|
964
|
+
GW_OPEN_DETACH.hidden = false;
|
|
965
|
+
} else {
|
|
966
|
+
GATEWAY_WORD.textContent = '';
|
|
967
|
+
GW_OPEN_ATTACH.hidden = false;
|
|
968
|
+
GW_OPEN_DETACH.hidden = true;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
|
|
972
|
+
function gwPreviews() {
|
|
973
|
+
$('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
|
|
974
|
+
try { $('gw-front-host').textContent = new URL($('gw-front').value.trim()).host; }
|
|
975
|
+
catch { /* not a URL yet — the placeholder host stands */ }
|
|
976
|
+
}
|
|
977
|
+
function gwShapeChanged() { gwPreviews(); gwCheck(); }
|
|
978
|
+
for (const r of document.querySelectorAll('input[name=gwShape]')) r.addEventListener('change', gwShapeChanged);
|
|
979
|
+
// Typing in the blank IS choosing that shape.
|
|
980
|
+
$('gw-name').addEventListener('focus', () => {
|
|
981
|
+
const r = document.querySelector('input[name=gwShape][value=front]');
|
|
982
|
+
if (!r.checked) { r.checked = true; gwShapeChanged(); }
|
|
983
|
+
});
|
|
984
|
+
GW_OPEN_ATTACH.addEventListener('click', () => {
|
|
985
|
+
gwShapeChanged();
|
|
986
|
+
solWindow.show('gateway-attach-form', 'Attach to a gateway');
|
|
987
|
+
});
|
|
988
|
+
GW_OPEN_DETACH.addEventListener('click', () => {
|
|
989
|
+
$('gw-detach-fronted').hidden = !gwState?.frontActor;
|
|
990
|
+
$('gw-detach-plain').hidden = !!gwState?.frontActor;
|
|
991
|
+
solWindow.show('gateway-detach-form', 'Detach from gateway');
|
|
992
|
+
});
|
|
993
|
+
// Live availability, asked through the agent (the front answers it without CORS).
|
|
994
|
+
let gwTimer = null;
|
|
995
|
+
function gwCheck() {
|
|
996
|
+
clearTimeout(gwTimer);
|
|
997
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
998
|
+
const name = $('gw-name').value.trim().toLowerCase();
|
|
999
|
+
$('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
|
|
1000
|
+
if (gwShape() !== 'front' || !front || !name) return;
|
|
1001
|
+
gwTimer = setTimeout(async () => {
|
|
1002
|
+
const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
|
|
1003
|
+
if (status !== 200 || !json) return;
|
|
1004
|
+
$('gw-name-msg').textContent = json.available
|
|
1005
|
+
? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
|
|
1006
|
+
: (json.reason || 'that name is taken');
|
|
1007
|
+
$('gw-name-msg').className = json.available ? 'hint' : 'warn';
|
|
1008
|
+
}, 300);
|
|
1009
|
+
}
|
|
1010
|
+
$('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
1011
|
+
$('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
1012
|
+
$('gw-attach').addEventListener('click', async () => {
|
|
1013
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
1014
|
+
const fronted = gwShape() === 'front';
|
|
1015
|
+
const name = $('gw-name').value.trim().toLowerCase();
|
|
1016
|
+
if (fronted && !name) { say('give the handle you want at the gateway', 'err'); return; }
|
|
1017
|
+
$('gw-attach').disabled = true;
|
|
1018
|
+
// Pod-based sends no name: the door's label is the agent's business, not
|
|
1019
|
+
// the user's, and the agent picks a free variant by itself.
|
|
1020
|
+
const r = await write('/gateway',
|
|
1021
|
+
{ action: 'attach', front, ...(fronted ? { handle: name, fronted: true } : {}) },
|
|
1022
|
+
fronted ? 'attached — the agent is restarting to publish under the gateway handle; reload in a moment'
|
|
1023
|
+
: 'attached — your mail now arrives through the gateway, filtered');
|
|
1024
|
+
$('gw-attach').disabled = false;
|
|
1025
|
+
if (r) { closePanels(); refreshGateway(); }
|
|
1026
|
+
});
|
|
1027
|
+
$('gw-detach').addEventListener('click', async () => {
|
|
1028
|
+
const fronted = !!gwState?.frontActor;
|
|
1029
|
+
const r = await write('/gateway', { action: 'forget' },
|
|
1030
|
+
fronted ? 'detached — the agent is restarting under your pod\'s own name; reload in a moment'
|
|
1031
|
+
: 'detached — the actor was republished advertising your pod\'s own inbox');
|
|
1032
|
+
if (r) { closePanels(); refreshGateway(); }
|
|
1033
|
+
});
|
|
937
1034
|
|
|
938
1035
|
$('inbox-keep').addEventListener('click', () => {
|
|
939
1036
|
dismissed = true;
|
package/web/admin/index.html
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
:root { color-scheme: light dark; font-size: 112.5%;
|
|
16
16
|
--bar-col: 1280px; /* the bar centres on the same column */
|
|
17
17
|
--link: #1a4f8a; --heading: #5b3a8e;
|
|
18
|
-
--ink: #1b1c1e; --muted: #
|
|
18
|
+
--ink: #1b1c1e; --muted: #4a4e53;
|
|
19
19
|
--btn-bg: #f4efe6; --btn-bg-hover: #ece3d2; --btn-bg-active: #e2d5bd; --btn-edge: #8a7a61;
|
|
20
20
|
--field-bg: #ffffff; --field-bg-hover: #f7f4ee; --field-edge: #767676;
|
|
21
21
|
--danger: #b00020; --ring: var(--link);
|
|
22
22
|
--chevron: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23444444' stroke-width='1.6' stroke-linecap='round'/></svg>"); }
|
|
23
23
|
@media (prefers-color-scheme: dark) {
|
|
24
24
|
:root { --link: #7fb3e8; --heading: #b9a3e3;
|
|
25
|
-
--ink: #e8eaed; --muted: #
|
|
25
|
+
--ink: #e8eaed; --muted: #b9bdc2;
|
|
26
26
|
--btn-bg: #35302a; --btn-bg-hover: #423b31; --btn-bg-active: #4d4436; --btn-edge: #9a8d7a;
|
|
27
27
|
--field-bg: #383d44; --field-bg-hover: #40464e; --field-edge: #9aa0a6;
|
|
28
28
|
--danger: #ff8a8a;
|
|
@@ -98,7 +98,8 @@ p { margin: .5rem 0; }
|
|
|
98
98
|
fieldset { border: 1px solid #bbb; border-radius: .4rem; margin: 1rem 0; padding: .75rem 1rem 1rem; }
|
|
99
99
|
legend { padding: 0 .3rem; font-weight: 600; color: var(--heading); }
|
|
100
100
|
label { display: block; margin: .75rem 0 .2rem; }
|
|
101
|
-
.hint { color: var(--muted); font-size:
|
|
101
|
+
.hint { color: var(--muted); font-size: 1rem; margin: .15rem 0 0; }
|
|
102
|
+
.choice input[type=text] { width: 9ch; display: inline-block; padding: .15rem .3rem; }
|
|
102
103
|
/* SETTINGS: light wells, unmistakably fields, in both schemes. */
|
|
103
104
|
input[type=text], input[type=email], input[type=url], input[type=password], textarea {
|
|
104
105
|
font: inherit; width: 100%; padding: .5rem; box-sizing: border-box;
|
|
@@ -314,9 +315,13 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
314
315
|
</span>
|
|
315
316
|
</section>
|
|
316
317
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
<span id="gateway-ctl" hidden>
|
|
319
|
+
<span id="gateway-word"></span>
|
|
320
|
+
<button type="button" id="gateway-open-attach" class="inline" hidden
|
|
321
|
+
title=" Attach this account to a mail-filtering gateway">Attach to a gateway</button>
|
|
322
|
+
<button type="button" id="gateway-open-detach" class="inline danger" hidden
|
|
323
|
+
title=" Go back to your pod's own inbox">Detach from gateway</button>
|
|
324
|
+
</span>
|
|
320
325
|
|
|
321
326
|
<section id="pane-group" hidden>
|
|
322
327
|
<!-- Both queues are consequences of a moderation setting: with the setting off
|
|
@@ -421,6 +426,45 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
421
426
|
<!-- Every consequence is written here rather than assembled in script: the
|
|
422
427
|
page asking "are you sure" has to say what it is sure about, and this is
|
|
423
428
|
the only record of it. Script picks which block to show, nothing else. -->
|
|
429
|
+
<!-- The gateway popups: attach proves the pod with this agent's own credential;
|
|
430
|
+
detach republishes the pod's own inbox. -->
|
|
431
|
+
<form id="gateway-attach-form" hidden>
|
|
432
|
+
<p class="hint">A gateway filters your fediverse mail before it reaches your pod: each
|
|
433
|
+
delivery is verified at its door, spam is dropped, and the rest lands in your pod as
|
|
434
|
+
before. Attaching proves the pod with this agent's own credential — no password
|
|
435
|
+
leaves this machine.</p>
|
|
436
|
+
<label for="gw-front">gateway</label>
|
|
437
|
+
<input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
|
|
438
|
+
<fieldset>
|
|
439
|
+
<legend>Your fediverse handle</legend>
|
|
440
|
+
<label class="choice"><input type="radio" name="gwShape" value="pod" checked>
|
|
441
|
+
Keep my pod-based handle: <strong id="gw-pod-preview"></strong><br>
|
|
442
|
+
<span class="hint">Your address stays tied to your own pod — you can drop the
|
|
443
|
+
gateway any time and keep everything.</span></label>
|
|
444
|
+
<label class="choice"><input type="radio" name="gwShape" value="front">
|
|
445
|
+
Create a handle at the gateway:
|
|
446
|
+
@<input type="text" id="gw-name" placeholder="name" autocomplete="off"
|
|
447
|
+
autocapitalize="off" spellcheck="false">@<span id="gw-front-host">fedipod.net</span><br>
|
|
448
|
+
<span class="hint">Your address lives on the gateway's domain, but not your data
|
|
449
|
+
or key; you can change pods later and keep the handle.</span>
|
|
450
|
+
<span id="gw-name-msg" class="hint"></span></label>
|
|
451
|
+
</fieldset>
|
|
452
|
+
<p>
|
|
453
|
+
<button id="gw-attach" type="button" class="primary"
|
|
454
|
+
title=" Create the gateway account and point your mail through its door">Attach</button>
|
|
455
|
+
</p>
|
|
456
|
+
</form>
|
|
457
|
+
<form id="gateway-detach-form" hidden>
|
|
458
|
+
<p class="hint" id="gw-detach-plain">Deliveries go back to your pod's own inbox — the
|
|
459
|
+
actor is republished advertising it. Your name and data never moved.</p>
|
|
460
|
+
<p class="warn" id="gw-detach-fronted" hidden>This identity publishes under the gateway's
|
|
461
|
+
name. Detaching moves every published id back to the pod — a rename other servers
|
|
462
|
+
see, not just a mail change.</p>
|
|
463
|
+
<p>
|
|
464
|
+
<button id="gw-detach" type="button" class="primary"
|
|
465
|
+
title=" Forget the gateway and republish the actor with the pod inbox">Detach</button>
|
|
466
|
+
</p>
|
|
467
|
+
</form>
|
|
424
468
|
<form id="confirm-form" hidden>
|
|
425
469
|
<div id="warn-rotate-key" class="warn" hidden>
|
|
426
470
|
<p>A new keypair replaces the one in this home and the actor is republished so other
|
|
@@ -3,422 +3,41 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>FediPod —
|
|
6
|
+
<title>FediPod — access the Fediverse and ATProto from a Solid pod</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
|
|
9
9
|
@media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
|
|
10
10
|
body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
|
|
11
11
|
h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
|
|
12
|
-
h2 { font-size: 1.2rem; margin:
|
|
12
|
+
h2 { font-size: 1.2rem; margin: 1.4rem 0 .4rem; }
|
|
13
13
|
p.lede { color: var(--dim); margin-top: 0; }
|
|
14
|
-
fieldset { border: 1px solid var(--line); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
|
|
15
|
-
legend { font-weight: 600; padding: 0 .4rem; }
|
|
16
|
-
.choices { display: flex; flex-direction: column; gap: .5rem; }
|
|
17
|
-
.choices button { padding: .7rem .8rem; border: 1px solid var(--line); border-radius: 10px;
|
|
18
|
-
background: transparent; font: inherit; cursor: pointer; text-align: left; }
|
|
19
|
-
.choices button[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
20
|
-
.choices button b { display: block; }
|
|
21
|
-
.choices button span { color: var(--dim); font-size: .95rem; }
|
|
22
|
-
label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
|
|
23
|
-
input[type=text], input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
|
|
24
|
-
border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
|
|
25
|
-
.msg { min-height: 1.3em; font-size: .95rem; margin-top: .3rem; }
|
|
26
|
-
.msg.ok { color: var(--accent); } .msg.no { color: #a4433a; }
|
|
27
14
|
.hint { color: var(--dim); font-size: .95rem; }
|
|
28
|
-
.primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
|
|
29
|
-
padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
|
|
30
|
-
.primary[disabled] { opacity: .5; cursor: not-allowed; }
|
|
31
|
-
[hidden] { display: none !important; }
|
|
32
15
|
code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
|
|
33
16
|
pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
|
|
34
17
|
ol.steps li { margin: .5rem 0; }
|
|
18
|
+
[hidden] { display: none !important; }
|
|
35
19
|
</style>
|
|
36
20
|
</head>
|
|
37
21
|
<body>
|
|
38
22
|
<h1>FediPod - join the fediverse from a Solid pod.</h1>
|
|
39
|
-
<p class="lede">Welcome to
|
|
23
|
+
<p class="lede">Welcome to <b>FediPod</b>! Here, you can get a free pod and fediverse account or link to your existing pod/account.</p>
|
|
24
|
+
|
|
25
|
+
<p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong><br>
|
|
26
|
+
— install or update an existing install with <code id="current-version-cmd"></code></p>
|
|
40
27
|
|
|
41
28
|
<p>
|
|
42
29
|
FediPod is experimental and free to use. See the <a href="https://github.com/jeff-zucker/FediPod">FediPod repository</a> for details.
|
|
43
30
|
</p>
|
|
44
31
|
|
|
45
|
-
<p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong>
|
|
46
|
-
— update an existing install with <code id="current-version-cmd"></code></p>
|
|
47
|
-
|
|
48
|
-
<fieldset>
|
|
49
|
-
<legend>What do you want to do?</legend>
|
|
50
|
-
<div class="choices" role="group" aria-label="Path">
|
|
51
|
-
<button type="button" id="pick-solo" aria-pressed="false"><b>Create a new solo account</b><span>a fediverse account for one person, on a pod you own</span></button>
|
|
52
|
-
<button type="button" id="pick-group" aria-pressed="false"><b>Create a new group account</b><span>an account people join — it carries members' posts to the whole group</span></button>
|
|
53
|
-
<button type="button" id="pick-manage" aria-pressed="false"><b>Manage an existing account</b><span>attach it to this gateway, detach it, or transfer in</span></button>
|
|
54
|
-
</div>
|
|
55
|
-
</fieldset>
|
|
56
|
-
|
|
57
|
-
<!-- create: the signup itself — prove the pod, take the name, then install -->
|
|
58
|
-
<form id="create-form" hidden>
|
|
59
|
-
<h2 id="create-title">Create a new solo account</h2>
|
|
60
|
-
<p class="hint" id="create-group-hint" hidden>A group is an account people join: members post to it, and it
|
|
61
|
-
carries each member's post to everyone in the group. Mastodon users can join with no pod of their own.
|
|
62
|
-
A group needs a pod of its own.</p>
|
|
63
|
-
<p class="hint">Your account lives on a pod you own; this gateway filters its mail.</p>
|
|
64
|
-
|
|
65
|
-
<label for="create-pod">Your pod address</label>
|
|
66
|
-
<input type="url" id="create-pod" autocomplete="off" placeholder="https://you.solidcommunity.net/">
|
|
67
|
-
<p class="hint">No pod yet? Any Solid pod host that offers <em>https subdomains</em> works, or a Community
|
|
68
|
-
Solid Server you run yourself. Get the pod, then come back here.</p>
|
|
69
|
-
<p id="create-pods" class="hint" hidden>This host also offers pods — you can take one instead of bringing
|
|
70
|
-
your own; it stays exportable, so you can leave with everything.</p>
|
|
71
|
-
|
|
72
|
-
<label for="create-issuer">Where you log in (your identity provider)</label>
|
|
73
|
-
<input type="url" id="create-issuer" autocomplete="off" placeholder="https://solidcommunity.net">
|
|
74
|
-
|
|
75
|
-
<label for="create-handle">Your handle (the name in your address; permanent)</label>
|
|
76
|
-
<input type="text" id="create-handle" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
77
|
-
inputmode="latin" placeholder="you" aria-describedby="create-handle-msg">
|
|
78
|
-
<p class="msg" id="create-handle-msg" aria-live="polite"></p>
|
|
79
|
-
|
|
80
|
-
<label>Your fediverse address</label>
|
|
81
|
-
<div class="choices" role="group" aria-label="Address">
|
|
82
|
-
<button type="button" id="addr-pod" aria-pressed="false"><b id="addr-pod-name">@you@your.pod</b><span>your handle is tied to your pod — you can drop the gateway and keep everything</span></button>
|
|
83
|
-
<button type="button" id="addr-front" aria-pressed="false"><b id="addr-front-name">@you@this.host</b><span>your handle is tied to the gateway — you can move pods and keep your handle</span></button>
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<p style="margin-top:1.1rem">
|
|
87
|
-
<button type="button" id="create-continue" class="primary" disabled>Sign in with your pod & create</button>
|
|
88
|
-
</p>
|
|
89
|
-
<p class="hint">Signing in proves the pod is yours — no password ever reaches this gateway.</p>
|
|
90
|
-
<p class="hint" id="create-note" hidden></p>
|
|
91
|
-
</form>
|
|
92
|
-
|
|
93
|
-
<!-- what a finished signup hands over: the one install command -->
|
|
94
|
-
<section id="create-done" hidden></section>
|
|
95
|
-
|
|
96
|
-
<!-- manage: the existing-account actions, one panel each -->
|
|
97
|
-
<section id="manage-form" hidden>
|
|
98
|
-
<div class="choices" role="group" aria-label="Manage">
|
|
99
|
-
<button type="button" id="pick-attach" aria-pressed="false"><b>Attach to this gateway</b><span>filter your mail through this door — your address stays yours</span></button>
|
|
100
|
-
<button type="button" id="pick-detach" aria-pressed="false"><b>Detach from this gateway</b><span>go back to unfiltered delivery</span></button>
|
|
101
|
-
<button type="button" id="pick-transfer" aria-pressed="false"><b>Transfer a fediverse account to FediPod</b><span>move here from Mastodon and friends</span></button>
|
|
102
|
-
</div>
|
|
103
|
-
</section>
|
|
104
|
-
|
|
105
|
-
<!-- attach: an existing FediPod points its inbox here; identity stays put -->
|
|
106
|
-
<form id="attach-form" hidden>
|
|
107
|
-
<h2>Attach an existing FediPod account</h2>
|
|
108
|
-
<p class="hint">Your account stays <code>@you@your.pod</code> — other servers keep finding you at your own
|
|
109
|
-
address. Attaching only changes where your <em>mail</em> arrives: your actor advertises this gateway's door
|
|
110
|
-
as its inbox, and everything that passes verification lands in your pod as before.</p>
|
|
111
|
-
|
|
112
|
-
<label for="pod-url">Your pod address</label>
|
|
113
|
-
<input type="url" id="pod-url" autocomplete="off" placeholder="https://you.solidcommunity.net/">
|
|
114
|
-
<p class="hint">If your FediPod uses a custom root, give the full home URL (ending in <code>/</code>);
|
|
115
|
-
a bare pod address assumes the default <code>activitypods-js/</code>.</p>
|
|
116
|
-
|
|
117
|
-
<label for="issuer">Where you log in (your identity provider)</label>
|
|
118
|
-
<input type="url" id="issuer" autocomplete="off" placeholder="https://solidcommunity.net">
|
|
119
|
-
|
|
120
|
-
<label for="slug">Your door's name here</label>
|
|
121
|
-
<input type="text" id="slug" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
122
|
-
inputmode="latin" placeholder="you" aria-describedby="slug-msg">
|
|
123
|
-
<p class="msg" id="slug-msg" aria-live="polite"></p>
|
|
124
|
-
<p class="hint">Only the door is named here — it is a routing address for servers, not your handle.</p>
|
|
125
|
-
|
|
126
|
-
<p style="margin-top:1.1rem">
|
|
127
|
-
<button type="button" id="attach-continue" class="primary" disabled>Sign in with your pod & attach</button>
|
|
128
|
-
</p>
|
|
129
|
-
<p class="hint">Signing in proves the pod is yours — no password ever reaches this gateway.</p>
|
|
130
|
-
<p class="hint" id="attach-note" hidden></p>
|
|
131
|
-
</form>
|
|
132
|
-
|
|
133
|
-
<!-- detach: done on the user's own agent; the gateway cannot reach in -->
|
|
134
|
-
<section id="detach-form" hidden>
|
|
135
|
-
<h2>Detach an existing FediPod account</h2>
|
|
136
|
-
<p class="hint">Detaching is yours to do, not this gateway's — your agent republishes your actor with your
|
|
137
|
-
pod's own inbox, and deliveries go straight there again. On your machine:</p>
|
|
138
|
-
<pre>node bin/fedipod.mjs gateway --detach</pre>
|
|
139
|
-
<p class="hint">Your name and data never moved, so nothing else changes. The door you had here goes quiet
|
|
140
|
-
and its name can be reused.</p>
|
|
141
|
-
</section>
|
|
142
|
-
|
|
143
|
-
<!-- transfer: the standard fediverse move, aimed at a FediPod -->
|
|
144
|
-
<section id="transfer-form" hidden>
|
|
145
|
-
<h2>Transfer a fediverse account to FediPod</h2>
|
|
146
|
-
<p class="hint">The fediverse's standard migration works toward a FediPod: your followers move over
|
|
147
|
-
automatically, and your old profile redirects. Posts stay on the old server — no fediverse migration
|
|
148
|
-
carries them.</p>
|
|
149
|
-
<ol class="steps">
|
|
150
|
-
<li><strong>Have a FediPod</strong> — create one first (the first option above).</li>
|
|
151
|
-
<li>On your agent's admin page, set <strong>new followers</strong> to <em>accepted automatically</em>,
|
|
152
|
-
so the wave of arriving followers doesn't queue.</li>
|
|
153
|
-
<li>Tell your FediPod about the old account:
|
|
154
|
-
<pre>node bin/fedipod.mjs alias --add @you@old.server</pre></li>
|
|
155
|
-
<li>On the old server: Preferences → Account → <em>Move to a different account</em>, naming your
|
|
156
|
-
FediPod address.</li>
|
|
157
|
-
<li>Bring the rest over from the old server's CSV export:
|
|
158
|
-
<pre>node bin/fedipod.mjs import following_accounts.csv blocked_accounts.csv muted_accounts.csv lists.csv</pre></li>
|
|
159
|
-
</ol>
|
|
160
|
-
</section>
|
|
161
|
-
|
|
162
|
-
<script src="/solid-client-authn.bundle.js"></script>
|
|
163
32
|
<script>
|
|
164
|
-
const $ = (id) => document.getElementById(id);
|
|
165
|
-
let offersPods = false, slugOk = false;
|
|
166
|
-
|
|
167
33
|
fetch('/api/handle?handle=__probe__').then(r => r.json()).then(d => {
|
|
168
|
-
offersPods = !!d.offersPods;
|
|
169
|
-
$('create-pods').hidden = !offersPods;
|
|
170
34
|
if (d.version) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
35
|
+
document.getElementById('current-version-num').textContent = d.version;
|
|
36
|
+
document.getElementById('current-version-cmd').textContent = `curl -fsSL ${location.origin}/install | sh`;
|
|
37
|
+
document.getElementById('current-version').hidden = false;
|
|
174
38
|
}
|
|
175
39
|
}).catch(() => {});
|
|
176
|
-
|
|
177
|
-
const picks = ['pick-solo', 'pick-group', 'pick-manage'];
|
|
178
|
-
const panels = { 'pick-solo': 'create-form', 'pick-group': 'create-form',
|
|
179
|
-
'pick-manage': 'manage-form' };
|
|
180
|
-
const subPicks = ['pick-attach', 'pick-detach', 'pick-transfer'];
|
|
181
|
-
const subPanels = { 'pick-attach': 'attach-form', 'pick-detach': 'detach-form',
|
|
182
|
-
'pick-transfer': 'transfer-form' };
|
|
183
|
-
let createKind = 'person';
|
|
184
|
-
function subPick(which) {
|
|
185
|
-
for (const id of subPicks) {
|
|
186
|
-
$(id).setAttribute('aria-pressed', String(id === which));
|
|
187
|
-
$(subPanels[id]).hidden = id !== which;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
function pick(which) {
|
|
191
|
-
for (const id of picks) $(id).setAttribute('aria-pressed', String(id === which));
|
|
192
|
-
for (const panel of new Set(Object.values(panels))) $(panel).hidden = panels[which] !== panel;
|
|
193
|
-
$('create-done').hidden = true;
|
|
194
|
-
if (which !== 'pick-manage') subPick(null);
|
|
195
|
-
if (panels[which] === 'create-form') {
|
|
196
|
-
createKind = which === 'pick-group' ? 'group' : 'person';
|
|
197
|
-
$('create-title').textContent = createKind === 'group'
|
|
198
|
-
? 'Create a new group account' : 'Create a new solo account';
|
|
199
|
-
$('create-group-hint').hidden = createKind !== 'group';
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
for (const id of picks) $(id).onclick = () => pick(id);
|
|
203
|
-
for (const id of subPicks) $(id).onclick = () => subPick(id);
|
|
204
|
-
|
|
205
|
-
// Suggest the door name from the pod's own first label, and the identity
|
|
206
|
-
// provider from the pod's apex domain — both still editable.
|
|
207
|
-
$('pod-url').addEventListener('input', () => {
|
|
208
|
-
try {
|
|
209
|
-
const u = new URL($('pod-url').value.trim());
|
|
210
|
-
const label = u.hostname.split('.')[0];
|
|
211
|
-
if (!$('slug').value && /^[a-z0-9-]{1,63}$/.test(label)) {
|
|
212
|
-
$('slug').value = label;
|
|
213
|
-
$('slug').dispatchEvent(new Event('input'));
|
|
214
|
-
}
|
|
215
|
-
if (!$('issuer').value) {
|
|
216
|
-
const parts = u.hostname.split('.');
|
|
217
|
-
$('issuer').value = 'https://' + (parts.length > 2 ? parts.slice(1).join('.') : u.hostname);
|
|
218
|
-
}
|
|
219
|
-
} catch { /* not a URL yet */ }
|
|
220
|
-
refresh();
|
|
221
|
-
});
|
|
222
|
-
$('issuer').addEventListener('input', refresh);
|
|
223
|
-
|
|
224
|
-
let t = null;
|
|
225
|
-
$('slug').addEventListener('input', () => {
|
|
226
|
-
slugOk = false; $('slug-msg').textContent = ''; $('slug-msg').className = 'msg';
|
|
227
|
-
clearTimeout(t);
|
|
228
|
-
const h = $('slug').value.trim().toLowerCase();
|
|
229
|
-
if (!h) { refresh(); return; }
|
|
230
|
-
t = setTimeout(async () => {
|
|
231
|
-
try {
|
|
232
|
-
const d = await (await fetch('/api/handle?handle=' + encodeURIComponent(h))).json();
|
|
233
|
-
slugOk = d.available;
|
|
234
|
-
$('slug-msg').textContent = d.available ? 'this door is free' : (d.reason || 'taken');
|
|
235
|
-
$('slug-msg').className = 'msg ' + (d.available ? 'ok' : 'no');
|
|
236
|
-
} catch { $('slug-msg').textContent = 'could not check'; $('slug-msg').className = 'msg no'; }
|
|
237
|
-
refresh();
|
|
238
|
-
}, 250);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
function refresh() {
|
|
242
|
-
const podOk = /^https:\/\/\S+/.test($('pod-url').value.trim());
|
|
243
|
-
const issuerOk = /^https:\/\/\S+/.test($('issuer').value.trim());
|
|
244
|
-
$('attach-continue').disabled = !(podOk && slugOk && issuerOk);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// The user's pod home: their agent tree on the pod. A bare pod root gets
|
|
248
|
-
// the default root; anything with a path is taken as given.
|
|
249
|
-
function podHomeOf(raw) {
|
|
250
|
-
const u = new URL(raw.trim());
|
|
251
|
-
if (u.pathname === '/') u.pathname = '/activitypods-js/';
|
|
252
|
-
else if (!u.pathname.endsWith('/')) u.pathname += '/';
|
|
253
|
-
u.search = ''; u.hash = '';
|
|
254
|
-
return u.href;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
$('attach-continue').onclick = () => {
|
|
258
|
-
const auth = window.solidClientAuthentication;
|
|
259
|
-
const n = $('attach-note');
|
|
260
|
-
if (!auth) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
|
|
261
|
-
sessionStorage.setItem('fp-attach', JSON.stringify({
|
|
262
|
-
slug: $('slug').value.trim().toLowerCase(),
|
|
263
|
-
podHome: podHomeOf($('pod-url').value),
|
|
264
|
-
kind: 'person',
|
|
265
|
-
}));
|
|
266
|
-
auth.login({
|
|
267
|
-
oidcIssuer: $('issuer').value.trim(),
|
|
268
|
-
redirectUrl: location.origin + '/',
|
|
269
|
-
clientName: 'FediPod gateway',
|
|
270
|
-
}).catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
// ---- the create (signup) form ----
|
|
274
|
-
let createSlugOk = false, addrShape = null;
|
|
275
|
-
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
|
276
|
-
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
277
|
-
|
|
278
|
-
// The two address previews follow the handle and pod as they are typed.
|
|
279
|
-
function addrPreview() {
|
|
280
|
-
const h = $('create-handle').value.trim().toLowerCase() || 'you';
|
|
281
|
-
let podHost = 'your.pod';
|
|
282
|
-
try { podHost = new URL($('create-pod').value.trim()).hostname; } catch { /* not a URL yet */ }
|
|
283
|
-
$('addr-pod-name').textContent = `@${h}@${podHost}`;
|
|
284
|
-
$('addr-front-name').textContent = `@${h}@${location.host}`;
|
|
285
|
-
}
|
|
286
|
-
function createRefresh() {
|
|
287
|
-
const podOk = /^https:\/\/\S+/.test($('create-pod').value.trim());
|
|
288
|
-
const issuerOk = /^https:\/\/\S+/.test($('create-issuer').value.trim());
|
|
289
|
-
$('create-continue').disabled = !(podOk && issuerOk && createSlugOk && addrShape);
|
|
290
|
-
}
|
|
291
|
-
$('addr-pod').onclick = () => { addrShape = 'pod';
|
|
292
|
-
$('addr-pod').setAttribute('aria-pressed', 'true');
|
|
293
|
-
$('addr-front').setAttribute('aria-pressed', 'false'); createRefresh(); };
|
|
294
|
-
$('addr-front').onclick = () => { addrShape = 'front';
|
|
295
|
-
$('addr-front').setAttribute('aria-pressed', 'true');
|
|
296
|
-
$('addr-pod').setAttribute('aria-pressed', 'false'); createRefresh(); };
|
|
297
|
-
|
|
298
|
-
$('create-pod').addEventListener('input', () => {
|
|
299
|
-
try {
|
|
300
|
-
const u = new URL($('create-pod').value.trim());
|
|
301
|
-
const label = u.hostname.split('.')[0];
|
|
302
|
-
if (!$('create-handle').value && /^[a-z0-9-]{1,63}$/.test(label)) {
|
|
303
|
-
$('create-handle').value = label;
|
|
304
|
-
$('create-handle').dispatchEvent(new Event('input'));
|
|
305
|
-
}
|
|
306
|
-
if (!$('create-issuer').value) {
|
|
307
|
-
const parts = u.hostname.split('.');
|
|
308
|
-
$('create-issuer').value = 'https://' + (parts.length > 2 ? parts.slice(1).join('.') : u.hostname);
|
|
309
|
-
}
|
|
310
|
-
} catch { /* not a URL yet */ }
|
|
311
|
-
addrPreview(); createRefresh();
|
|
312
|
-
});
|
|
313
|
-
$('create-issuer').addEventListener('input', createRefresh);
|
|
314
|
-
|
|
315
|
-
let ct = null;
|
|
316
|
-
$('create-handle').addEventListener('input', () => {
|
|
317
|
-
createSlugOk = false; $('create-handle-msg').textContent = ''; $('create-handle-msg').className = 'msg';
|
|
318
|
-
addrPreview();
|
|
319
|
-
clearTimeout(ct);
|
|
320
|
-
const h = $('create-handle').value.trim().toLowerCase();
|
|
321
|
-
if (!h) { createRefresh(); return; }
|
|
322
|
-
ct = setTimeout(async () => {
|
|
323
|
-
try {
|
|
324
|
-
const d = await (await fetch('/api/handle?handle=' + encodeURIComponent(h))).json();
|
|
325
|
-
createSlugOk = d.available;
|
|
326
|
-
$('create-handle-msg').textContent = d.available ? 'this name is free' : (d.reason || 'taken');
|
|
327
|
-
$('create-handle-msg').className = 'msg ' + (d.available ? 'ok' : 'no');
|
|
328
|
-
} catch { $('create-handle-msg').textContent = 'could not check'; $('create-handle-msg').className = 'msg no'; }
|
|
329
|
-
createRefresh();
|
|
330
|
-
}, 250);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
$('create-continue').onclick = () => {
|
|
334
|
-
const auth = window.solidClientAuthentication;
|
|
335
|
-
const n = $('create-note');
|
|
336
|
-
if (!auth) { n.hidden = false; n.textContent = 'the sign-in library did not load — reload and try again'; return; }
|
|
337
|
-
sessionStorage.setItem('fp-create', JSON.stringify({
|
|
338
|
-
slug: $('create-handle').value.trim().toLowerCase(),
|
|
339
|
-
podHome: podHomeOf($('create-pod').value),
|
|
340
|
-
issuer: $('create-issuer').value.trim(),
|
|
341
|
-
kind: createKind,
|
|
342
|
-
fronted: addrShape === 'front',
|
|
343
|
-
}));
|
|
344
|
-
auth.login({
|
|
345
|
-
oidcIssuer: $('create-issuer').value.trim(),
|
|
346
|
-
redirectUrl: location.origin + '/',
|
|
347
|
-
clientName: 'FediPod gateway',
|
|
348
|
-
}).catch((e) => { n.hidden = false; n.textContent = 'sign-in failed to start: ' + e.message; });
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
// Back from the identity provider: finish the signup with the proven login.
|
|
352
|
-
(async () => {
|
|
353
|
-
const auth = window.solidClientAuthentication;
|
|
354
|
-
if (!auth) return;
|
|
355
|
-
const info = await auth.handleIncomingRedirect().catch(() => null);
|
|
356
|
-
const pending = sessionStorage.getItem('fp-create');
|
|
357
|
-
if (!info?.isLoggedIn || !pending) return;
|
|
358
|
-
sessionStorage.removeItem('fp-create');
|
|
359
|
-
const p = JSON.parse(pending);
|
|
360
|
-
pick(p.kind === 'group' ? 'pick-group' : 'pick-solo');
|
|
361
|
-
const n = $('create-note');
|
|
362
|
-
n.hidden = false;
|
|
363
|
-
n.textContent = 'signed in as ' + info.webId + ' — creating…';
|
|
364
|
-
const res = await auth.fetch('/api/attach', {
|
|
365
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
366
|
-
body: JSON.stringify({ handle: p.slug, podHome: p.podHome, kind: p.kind, fronted: p.fronted }),
|
|
367
|
-
}).catch(() => null);
|
|
368
|
-
const d = res ? await res.json().catch(() => ({})) : {};
|
|
369
|
-
if (res && res.status === 201) {
|
|
370
|
-
const address = p.fronted ? d.address : `@${p.slug}@${new URL(p.podHome).hostname}`;
|
|
371
|
-
const flags = [
|
|
372
|
-
`--gateway ${d.frontActor || d.doorInbox}`,
|
|
373
|
-
`--secret ${d.hmacSecret}`,
|
|
374
|
-
`--pod ${p.podHome}`,
|
|
375
|
-
`--issuer ${p.issuer}`,
|
|
376
|
-
`--handle ${p.slug}`,
|
|
377
|
-
`--kind ${p.kind}`,
|
|
378
|
-
...(p.fronted ? ['--fronted'] : []),
|
|
379
|
-
];
|
|
380
|
-
const cmd = `curl -fsSL ${location.origin}/install | sh -s -- \\\n ` + flags.join(' \\\n ');
|
|
381
|
-
$('create-form').hidden = true;
|
|
382
|
-
const done = $('create-done');
|
|
383
|
-
done.hidden = false;
|
|
384
|
-
done.innerHTML = '<h2>' + esc(address) + '</h2>'
|
|
385
|
-
+ '<p>Created. Now install your agent on the machine it will live on — one command, then start it:</p>'
|
|
386
|
-
+ '<pre>' + esc(cmd) + '\n\nfedipod start</pre>'
|
|
387
|
-
+ '<p>Setup opens in your browser with everything already filled in. When it finishes, your '
|
|
388
|
-
+ 'account is live and your mail arrives through this gateway, filtered.</p>';
|
|
389
|
-
} else {
|
|
390
|
-
n.textContent = 'signup failed: ' + (d.error || (res ? 'HTTP ' + res.status : 'no response'));
|
|
391
|
-
}
|
|
392
|
-
})();
|
|
393
|
-
|
|
394
|
-
// Back from the identity provider: finish the attach with the proven login.
|
|
395
|
-
(async () => {
|
|
396
|
-
const auth = window.solidClientAuthentication;
|
|
397
|
-
if (!auth) return;
|
|
398
|
-
const info = await auth.handleIncomingRedirect().catch(() => null);
|
|
399
|
-
const pending = sessionStorage.getItem('fp-attach');
|
|
400
|
-
if (!info?.isLoggedIn || !pending) return;
|
|
401
|
-
sessionStorage.removeItem('fp-attach');
|
|
402
|
-
const p = JSON.parse(pending);
|
|
403
|
-
pick('pick-manage');
|
|
404
|
-
subPick('pick-attach');
|
|
405
|
-
const n = $('attach-note');
|
|
406
|
-
n.hidden = false;
|
|
407
|
-
n.textContent = 'signed in as ' + info.webId + ' — attaching…';
|
|
408
|
-
const res = await auth.fetch('/api/attach', {
|
|
409
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
410
|
-
body: JSON.stringify({ handle: p.slug, podHome: p.podHome, kind: p.kind }),
|
|
411
|
-
}).catch(() => null);
|
|
412
|
-
const d = res ? await res.json().catch(() => ({})) : {};
|
|
413
|
-
if (res && res.status === 201) {
|
|
414
|
-
n.innerHTML = 'Attached. Run this on the machine your agent lives on, then restart the agent:'
|
|
415
|
-
+ '<pre>' + d.command + '</pre>'
|
|
416
|
-
+ 'Your mail then arrives through <code>' + d.doorInbox + '</code>, filtered. Your name has not moved.';
|
|
417
|
-
} else {
|
|
418
|
-
n.textContent = 'attach failed: ' + (d.error || (res ? 'HTTP ' + res.status : 'no response'));
|
|
419
|
-
}
|
|
420
|
-
})();
|
|
421
40
|
</script>
|
|
422
|
-
<i>(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
41
|
+
<i style="text-align:right">(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
423
42
|
</body>
|
|
424
43
|
</html>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>FediPod — access the Fediverse and ATProto from a Solid pod</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
|
|
9
|
+
@media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
|
|
10
|
+
body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
|
|
11
|
+
h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
|
|
12
|
+
h2 { font-size: 1.2rem; margin: 1.4rem 0 .4rem; }
|
|
13
|
+
p.lede { color: var(--dim); margin-top: 0; }
|
|
14
|
+
.hint { color: var(--dim); font-size: .95rem; }
|
|
15
|
+
code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
|
|
16
|
+
pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
|
|
17
|
+
ol.steps li { margin: .5rem 0; }
|
|
18
|
+
[hidden] { display: none !important; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<h1>FediPod - join the fediverse from a Solid pod.</h1>
|
|
23
|
+
<p class="lede">Welcome to <b>FediPod</b! Here, you can get a free pod and fediverse account or link to your existing pod/account.</p>
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
<p>
|
|
27
|
+
FediPod is experimental and free to use. See the <a href="https://github.com/jeff-zucker/FediPod">FediPod repository</a> for details.
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<p class="hint" id="current-version" hidden>Current FediPod: <strong id="current-version-num"></strong><br>
|
|
31
|
+
— update an existing install with <code id="current-version-cmd"></code></p>
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
// Another host deploying this front serves the same page: the command names
|
|
35
|
+
// that host, not fedipod.net.
|
|
36
|
+
if (location.origin !== 'https://fedipod.net' && location.protocol.startsWith('http')) {
|
|
37
|
+
document.getElementById('install-cmd').textContent =
|
|
38
|
+
`curl -fsSL ${location.origin}/install | sh\n\nfedipod start`;
|
|
39
|
+
}
|
|
40
|
+
fetch('/api/handle?handle=__probe__').then(r => r.json()).then(d => {
|
|
41
|
+
if (d.version) {
|
|
42
|
+
document.getElementById('current-version-num').textContent = d.version;
|
|
43
|
+
document.getElementById('current-version-cmd').textContent = `curl -fsSL ${location.origin}/install | sh`;
|
|
44
|
+
document.getElementById('current-version').hidden = false;
|
|
45
|
+
}
|
|
46
|
+
}).catch(() => {});
|
|
47
|
+
</script>
|
|
48
|
+
<i style="text-align:right">(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|