fedipod 0.9.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/lease.mjs +10 -4
- package/package.json +1 -1
- package/run-agent.mjs +3 -1
- 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/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('Restart the agent (or `fedipod up`) to republish 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
|
+
published at the next restart. The admin page's Gateway panel is the same
|
|
202
|
+
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,24 @@ 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** panel attaches this account to a mail-filtering gateway (a
|
|
78
|
+
fedipod.net-style front), shows what its door has verified, and detaches it.
|
|
79
|
+
Give the gateway's address and the name you want there — the panel checks the
|
|
80
|
+
name is free as you type — and choose a pod-based name (`@you@your.pod`) or a
|
|
81
|
+
gateway-based name (`@you@the-gateway`); the gateway account is created
|
|
82
|
+
automatically either way. Attaching proves the pod with the agent's own
|
|
83
|
+
credential, so no password is typed anywhere. Detaching republishes your
|
|
84
|
+
actor with your pod's own inbox.
|
|
85
|
+
|
|
73
86
|
## Moving here from another server
|
|
74
87
|
|
|
75
88
|
Open **Transfer an account here** on the action rail and add your old account
|
|
76
89
|
as an alias, set
|
|
77
90
|
**new followers** to *accepted automatically*, then trigger the move on the
|
|
78
|
-
old server
|
|
91
|
+
old server (on Mastodon: Preferences → Account → *Move to a different
|
|
92
|
+
account*); your followers arrive by themselves. Removing an alias asks
|
|
79
93
|
twice — servers still processing the move check it while they retry. The CSV
|
|
80
94
|
files from the old server's export are imported with the CLI; see
|
|
81
95
|
[CLI admin](cli.md).
|
package/lib/admin.mjs
CHANGED
|
@@ -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
|
|
@@ -592,6 +594,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
592
594
|
privateRoot: agent.readCredential?.()?.privateRoot || null,
|
|
593
595
|
mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
|
|
594
596
|
update: agent.updateInfo || null,
|
|
597
|
+
// What this process is running, against what a restart would run:
|
|
598
|
+
// the checkout can move while an agent stays up.
|
|
599
|
+
version: AGENT_VERSION,
|
|
600
|
+
versionOnDisk: versionOnDisk(),
|
|
595
601
|
pendingUpgrade: agent.pendingUpgrade || [],
|
|
596
602
|
// The connected Bluesky account, non-secret half. `connected` is the
|
|
597
603
|
// credential's word, so a config entry orphaned by a deleted
|
|
@@ -1423,6 +1429,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1423
1429
|
const cfg = { ...agent.store.getConfig() };
|
|
1424
1430
|
const g = { ...(cfg.gateway || {}) };
|
|
1425
1431
|
const inboxUrl = agent.publisher?.urls?.inbox;
|
|
1432
|
+
// Availability is a read: answered before the lease takeover below,
|
|
1433
|
+
// which every real gateway change does want.
|
|
1434
|
+
if (body.action === 'check') {
|
|
1435
|
+
const front = String(body.front || '').replace(/\/+$/, '');
|
|
1436
|
+
try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
|
|
1437
|
+
const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
|
|
1438
|
+
{ headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
|
|
1439
|
+
if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
|
|
1440
|
+
return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
|
|
1441
|
+
}
|
|
1426
1442
|
await agent.requestTakeover?.();
|
|
1427
1443
|
const persist = async () => {
|
|
1428
1444
|
cfg.gateway = g; agent.store.setConfig(cfg);
|
|
@@ -1478,6 +1494,56 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1478
1494
|
else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
|
|
1479
1495
|
return json(res, 200, { ok: true, mode: g.mode });
|
|
1480
1496
|
}
|
|
1497
|
+
// Attach through a multi-user front (fedipod.net and kin): the agent
|
|
1498
|
+
// proves the pod with its own credential — no browser, no password —
|
|
1499
|
+
// and the front answers with the door and the receipt secret.
|
|
1500
|
+
if (body.action === 'attach') {
|
|
1501
|
+
const front = String(body.front || '').replace(/\/+$/, '');
|
|
1502
|
+
let fu;
|
|
1503
|
+
try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
|
|
1504
|
+
if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
|
|
1505
|
+
return json(res, 400, { error: 'front must be https' });
|
|
1506
|
+
}
|
|
1507
|
+
if (agent.embedded) {
|
|
1508
|
+
return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
|
|
1509
|
+
}
|
|
1510
|
+
const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
|
|
1511
|
+
if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
|
|
1512
|
+
const fronted = body.fronted === true;
|
|
1513
|
+
const frontActor = `${front}/u/${handle}/ap/actor`;
|
|
1514
|
+
if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
|
|
1515
|
+
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` });
|
|
1516
|
+
}
|
|
1517
|
+
// Availability first, for a clean answer before anything is created.
|
|
1518
|
+
const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
|
|
1519
|
+
{ headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
|
|
1520
|
+
if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
|
|
1521
|
+
if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
|
|
1522
|
+
const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
|
|
1523
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1524
|
+
body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
|
|
1525
|
+
}).catch(() => null);
|
|
1526
|
+
if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
|
|
1527
|
+
const d = await attach.json().catch(() => ({}));
|
|
1528
|
+
if (attach.status !== 201) {
|
|
1529
|
+
return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
|
|
1530
|
+
{ error: d.error || `attach failed (HTTP ${attach.status})` });
|
|
1531
|
+
}
|
|
1532
|
+
g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
|
|
1533
|
+
if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
|
|
1534
|
+
if (fronted) g.frontActor = String(d.frontActor || frontActor);
|
|
1535
|
+
if (!g.mode || g.mode === 'off') g.mode = 'shadow';
|
|
1536
|
+
await persist();
|
|
1537
|
+
// Inbox-only applies live: the actor republishes advertising the
|
|
1538
|
+
// door. A front carries new ids, which are wired at startup — that
|
|
1539
|
+
// attach persists and owes a restart.
|
|
1540
|
+
if (!fronted) {
|
|
1541
|
+
await agent.publisher?.publishProfile();
|
|
1542
|
+
await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
|
|
1543
|
+
}
|
|
1544
|
+
return json(res, 200, { ok: true, mode: g.mode, url: g.url,
|
|
1545
|
+
...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
|
|
1546
|
+
}
|
|
1481
1547
|
if (body.action === 'forget') {
|
|
1482
1548
|
const wasLocked = g.mode === 'locked';
|
|
1483
1549
|
delete cfg.gateway; agent.store.setConfig(cfg);
|
|
@@ -1487,7 +1553,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1487
1553
|
await agent.publisher?.publishProfile(); // re-advertise the pod inbox
|
|
1488
1554
|
return json(res, 200, { ok: true, mode: 'off', forgotten: true });
|
|
1489
1555
|
}
|
|
1490
|
-
return json(res, 400, { error: 'action must be configure, mode or forget' });
|
|
1556
|
+
return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
|
|
1491
1557
|
}
|
|
1492
1558
|
// Symmetrical with /block, and open for the same reason: a block made
|
|
1493
1559
|
// by mistake is worth undoing before federation is even configured.
|
|
@@ -1534,14 +1600,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1534
1600
|
return { handler, masto, c2s, streaming };
|
|
1535
1601
|
}
|
|
1536
1602
|
|
|
1537
|
-
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null
|
|
1603
|
+
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
|
|
1604
|
+
// Injectable, so a test can put a checkout ahead of the running process
|
|
1605
|
+
// without editing the package.json of the machine running the test.
|
|
1606
|
+
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
1538
1607
|
const gate = makeGate(gateToken);
|
|
1539
1608
|
// Live, so the named origin appears the moment connect() reads the handle
|
|
1540
1609
|
// out of pod state — including for the OAuth redirect check in MastoApi.
|
|
1541
1610
|
// The https listener's port joins the authority set: same names, second port.
|
|
1542
1611
|
const allowed = new Authorities(port, handle);
|
|
1543
1612
|
agent.authorities = allowed;
|
|
1544
|
-
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
|
|
1613
|
+
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
|
|
1545
1614
|
|
|
1546
1615
|
// Loopback both ways: the canonical URL is https://localhost:<port>/, and
|
|
1547
1616
|
// "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/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",
|
package/run-agent.mjs
CHANGED
|
@@ -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 });
|
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
|
|
@@ -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>
|