fedipod 0.10.0 → 0.13.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 +1 -1
- package/cli.md +2 -2
- package/gui.md +12 -8
- package/lib/admin.mjs +71 -13
- package/lib/front-core.mjs +47 -0
- 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 +4 -2
- package/web/admin/admin.js +69 -31
- package/web/admin/index.html +50 -33
- package/web/front/admin.html +129 -0
- package/web/front/run.html +2 -1
package/bin/fedipod.mjs
CHANGED
|
@@ -1518,7 +1518,7 @@ if (cmd === 'up') {
|
|
|
1518
1518
|
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1519
1519
|
if (body.frontActor) {
|
|
1520
1520
|
console.log(`attached — this identity now publishes as ${body.address || body.frontActor}`);
|
|
1521
|
-
console.log('
|
|
1521
|
+
console.log('The agent is restarting itself to publish under the front.');
|
|
1522
1522
|
} else {
|
|
1523
1523
|
console.log(`attached — your mail now arrives through ${body.url}, filtered; your name has not moved.`);
|
|
1524
1524
|
console.log('Starting in shadow: the door filters, and the agent measures how much');
|
package/cli.md
CHANGED
|
@@ -198,8 +198,8 @@ fedipod gateway --detach
|
|
|
198
198
|
`--attach` asks the gateway itself: the agent proves the pod with its own
|
|
199
199
|
credential, and the gateway answers with the door and the receipt secret.
|
|
200
200
|
`--name` defaults to your handle; `--fronted` takes a gateway-based name,
|
|
201
|
-
|
|
202
|
-
action with the name checked as you type.
|
|
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.
|
|
203
203
|
Attaching points your actor's advertised inbox at a gateway's door, so
|
|
204
204
|
deliveries are verified and de-junked before they reach your pod; your name,
|
|
205
205
|
key and data stay on your pod. The URL and secret come from the gateway's
|
package/gui.md
CHANGED
|
@@ -74,14 +74,18 @@ accepting automatically.
|
|
|
74
74
|
|
|
75
75
|
## The gateway
|
|
76
76
|
|
|
77
|
-
The **
|
|
78
|
-
fedipod.net-style front)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
85
89
|
|
|
86
90
|
## Moving here from another server
|
|
87
91
|
|
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';
|
|
@@ -490,7 +490,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
490
490
|
// the handle, and the pod host its actor URL sits on.
|
|
491
491
|
let address = null;
|
|
492
492
|
if (live?.handle && live?.actor) {
|
|
493
|
-
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 */ }
|
|
494
497
|
}
|
|
495
498
|
return {
|
|
496
499
|
name, port, current,
|
|
@@ -568,6 +571,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
568
571
|
if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
|
|
569
572
|
const urls = agent.urls || agent.publisher?.urls || null;
|
|
570
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);
|
|
571
579
|
return json(res, 200, {
|
|
572
580
|
// permanent
|
|
573
581
|
handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
|
|
@@ -578,7 +586,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
578
586
|
// same way every other id is — computed here rather than in the page,
|
|
579
587
|
// which has no business knowing how they are made.
|
|
580
588
|
accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
|
|
581
|
-
address
|
|
589
|
+
address,
|
|
582
590
|
// editable
|
|
583
591
|
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
584
592
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
@@ -1445,6 +1453,31 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1445
1453
|
if (agent.publisher) agent.publisher.config.gateway = g;
|
|
1446
1454
|
await agent.store.flush();
|
|
1447
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);
|
|
1448
1481
|
if (body.action === 'configure') {
|
|
1449
1482
|
if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
|
|
1450
1483
|
if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
|
|
@@ -1507,18 +1540,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1507
1540
|
if (agent.embedded) {
|
|
1508
1541
|
return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
|
|
1509
1542
|
}
|
|
1510
|
-
const
|
|
1543
|
+
const named = !!String(body.handle || '').trim();
|
|
1544
|
+
let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
|
|
1511
1545
|
if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
|
|
1512
1546
|
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
1547
|
// Availability first, for a clean answer before anything is created.
|
|
1518
|
-
const
|
|
1548
|
+
const avail = async (h) => fetch(`${front}/api/handle?handle=${encodeURIComponent(h)}`,
|
|
1519
1549
|
{ headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
|
|
1550
|
+
let chk = await avail(handle);
|
|
1520
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
|
+
}
|
|
1521
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
|
+
}
|
|
1522
1566
|
const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
|
|
1523
1567
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1524
1568
|
body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
|
|
@@ -1535,21 +1579,35 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1535
1579
|
if (!g.mode || g.mode === 'off') g.mode = 'shadow';
|
|
1536
1580
|
await persist();
|
|
1537
1581
|
// Inbox-only applies live: the actor republishes advertising the
|
|
1538
|
-
// door. A front carries new ids, which are wired at startup —
|
|
1539
|
-
// attach
|
|
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.
|
|
1540
1585
|
if (!fronted) {
|
|
1541
1586
|
await agent.publisher?.publishProfile();
|
|
1542
1587
|
await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
|
|
1588
|
+
return json(res, 200, { ok: true, mode: g.mode, url: g.url });
|
|
1543
1589
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
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;
|
|
1546
1595
|
}
|
|
1547
1596
|
if (body.action === 'forget') {
|
|
1548
1597
|
const wasLocked = g.mode === 'locked';
|
|
1598
|
+
const wasFronted = !!g.frontActor;
|
|
1549
1599
|
delete cfg.gateway; agent.store.setConfig(cfg);
|
|
1550
1600
|
if (agent.publisher) agent.publisher.config.gateway = undefined;
|
|
1551
1601
|
await agent.store.flush();
|
|
1552
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
|
+
}
|
|
1553
1611
|
await agent.publisher?.publishProfile(); // re-advertise the pod inbox
|
|
1554
1612
|
return json(res, 200, { ok: true, mode: 'off', forgotten: true });
|
|
1555
1613
|
}
|
package/lib/front-core.mjs
CHANGED
|
@@ -130,6 +130,8 @@ function identFor(rec, policy = null) {
|
|
|
130
130
|
// host the front's own host, e.g. "fedipod.net" (for WebFinger subjects)
|
|
131
131
|
// frontOrigin "https://fedipod.net"
|
|
132
132
|
// lookup(handle) -> record | null the directory
|
|
133
|
+
// listDirectory() -> { handle: record } every row, for the admin roster
|
|
134
|
+
// removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
|
|
133
135
|
// podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
|
|
134
136
|
// podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
|
|
135
137
|
export async function routeFront(request, ctx) {
|
|
@@ -155,6 +157,51 @@ export async function routeFront(request, ctx) {
|
|
|
155
157
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
// The admin page: the host reading who has accounts here. The page signs in
|
|
161
|
+
// and calls the roster API below; a deploy with no admin supplies no page.
|
|
162
|
+
if (pathname === '/admin') {
|
|
163
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
164
|
+
if (!ctx.adminPage) return notFound();
|
|
165
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The roster: every directory row, secrets stripped, for the host's own
|
|
169
|
+
// eyes. The reader proves themself the way attach proves a pod — a
|
|
170
|
+
// Solid-OIDC token — and must be the WebID the deploy names as admin.
|
|
171
|
+
if (pathname === '/api/roster') {
|
|
172
|
+
if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
|
|
173
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
174
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
175
|
+
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
176
|
+
const rows = await ctx.listDirectory();
|
|
177
|
+
const accounts = Object.values(rows)
|
|
178
|
+
.map((r) => ({
|
|
179
|
+
handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
|
|
180
|
+
podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
|
|
181
|
+
address: `@${r.handle}@${ctx.host}`,
|
|
182
|
+
}))
|
|
183
|
+
.sort((a, b) => a.handle.localeCompare(b.handle));
|
|
184
|
+
return j(200, { host: ctx.host, accounts });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Revoke: the admin removes an account's row, so the server stops answering
|
|
188
|
+
// for the name. Nothing on the user's pod is touched. Attach-created rows go
|
|
189
|
+
// for good; a row seeded in the deploy's environment can only be removed there.
|
|
190
|
+
if (pathname === '/api/revoke' && request.method === 'POST') {
|
|
191
|
+
if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
|
|
192
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
193
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
194
|
+
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
195
|
+
let body;
|
|
196
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
197
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
198
|
+
if (!(await ctx.lookup(handle))) return j(404, { error: 'no such account' });
|
|
199
|
+
const removed = (await ctx.removeDirectory(handle)) === true;
|
|
200
|
+
return j(200, removed ? { handle, removed }
|
|
201
|
+
: { handle, removed: false,
|
|
202
|
+
reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
// Live handle check for the page: valid shape AND not already in the
|
|
159
206
|
// directory. Also tells the page whether this host offers pods.
|
|
160
207
|
if (pathname === '/api/handle') {
|
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.13.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 {
|
|
@@ -539,7 +539,9 @@ export class Agent {
|
|
|
539
539
|
this.schedTimer.unref();
|
|
540
540
|
// A CSV import interrupted by a restart or a handoff picks back up here.
|
|
541
541
|
this.importer?.resume();
|
|
542
|
-
|
|
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}`);
|
|
543
545
|
if (this.renamed) {
|
|
544
546
|
// The display name lives in the actor document, so a rename only
|
|
545
547
|
// reaches other servers once that is republished.
|
package/web/admin/admin.js
CHANGED
|
@@ -142,6 +142,7 @@ function render() {
|
|
|
142
142
|
.replace(/\/$/, '')],
|
|
143
143
|
];
|
|
144
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,6 +186,11 @@ 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);
|
|
@@ -237,7 +243,6 @@ function render() {
|
|
|
237
243
|
renderAliases();
|
|
238
244
|
renderOthers();
|
|
239
245
|
renderInbox();
|
|
240
|
-
renderGateway();
|
|
241
246
|
if (config.kind === 'group') {
|
|
242
247
|
// Its lists have no bound, so this page scrolls — see body.group in the CSS.
|
|
243
248
|
document.body.classList.add('group');
|
|
@@ -937,35 +942,62 @@ async function renderInbox() {
|
|
|
937
942
|
panel.hidden = false;
|
|
938
943
|
}
|
|
939
944
|
|
|
940
|
-
// The gateway
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
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() {
|
|
944
954
|
const { status, json: g } = await api('/gateway');
|
|
945
|
-
if (status !== 200 || !g) return;
|
|
946
|
-
|
|
955
|
+
if (status !== 200 || !g) return;
|
|
956
|
+
gwState = g;
|
|
957
|
+
GATEWAY_CTL.hidden = false;
|
|
947
958
|
if (g.configured) {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
$('gateway-attached').hidden = false;
|
|
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;
|
|
955
965
|
} else {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
if (!$('gw-name').value && config?.handle) $('gw-name').value = config.handle;
|
|
966
|
+
GATEWAY_WORD.textContent = '';
|
|
967
|
+
GW_OPEN_ATTACH.hidden = false;
|
|
968
|
+
GW_OPEN_DETACH.hidden = true;
|
|
960
969
|
}
|
|
961
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
|
+
});
|
|
962
993
|
// Live availability, asked through the agent (the front answers it without CORS).
|
|
994
|
+
let gwTimer = null;
|
|
963
995
|
function gwCheck() {
|
|
964
996
|
clearTimeout(gwTimer);
|
|
965
997
|
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
966
998
|
const name = $('gw-name').value.trim().toLowerCase();
|
|
967
999
|
$('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
|
|
968
|
-
if (!front || !name) return;
|
|
1000
|
+
if (gwShape() !== 'front' || !front || !name) return;
|
|
969
1001
|
gwTimer = setTimeout(async () => {
|
|
970
1002
|
const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
|
|
971
1003
|
if (status !== 200 || !json) return;
|
|
@@ -975,24 +1007,30 @@ function gwCheck() {
|
|
|
975
1007
|
$('gw-name-msg').className = json.available ? 'hint' : 'warn';
|
|
976
1008
|
}, 300);
|
|
977
1009
|
}
|
|
978
|
-
$('gw-front').addEventListener('input', gwCheck);
|
|
979
|
-
$('gw-name').addEventListener('input', gwCheck);
|
|
980
|
-
$('gw-attach').
|
|
1010
|
+
$('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
1011
|
+
$('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
1012
|
+
$('gw-attach').addEventListener('click', async () => {
|
|
981
1013
|
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
982
|
-
const fronted =
|
|
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; }
|
|
983
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.
|
|
984
1020
|
const r = await write('/gateway',
|
|
985
|
-
{ action: 'attach', front, handle:
|
|
986
|
-
fronted ? 'attached —
|
|
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'
|
|
987
1023
|
: 'attached — your mail now arrives through the gateway, filtered');
|
|
988
1024
|
$('gw-attach').disabled = false;
|
|
989
|
-
if (r)
|
|
990
|
-
};
|
|
991
|
-
$('gw-detach').
|
|
1025
|
+
if (r) { closePanels(); refreshGateway(); }
|
|
1026
|
+
});
|
|
1027
|
+
$('gw-detach').addEventListener('click', async () => {
|
|
1028
|
+
const fronted = !!gwState?.frontActor;
|
|
992
1029
|
const r = await write('/gateway', { action: 'forget' },
|
|
993
|
-
'detached — the
|
|
994
|
-
|
|
995
|
-
}
|
|
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
|
+
});
|
|
996
1034
|
|
|
997
1035
|
$('inbox-keep').addEventListener('click', () => {
|
|
998
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,36 +315,13 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
314
315
|
</span>
|
|
315
316
|
</section>
|
|
316
317
|
|
|
317
|
-
|
|
318
|
-
<
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
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>
|
|
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>
|
|
347
325
|
|
|
348
326
|
<section id="pane-group" hidden>
|
|
349
327
|
<!-- Both queues are consequences of a moderation setting: with the setting off
|
|
@@ -448,6 +426,45 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
448
426
|
<!-- Every consequence is written here rather than assembled in script: the
|
|
449
427
|
page asking "are you sure" has to say what it is sure about, and this is
|
|
450
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>
|
|
451
468
|
<form id="confirm-form" hidden>
|
|
452
469
|
<div id="warn-rotate-key" class="warn" hidden>
|
|
453
470
|
<p>A new keypair replaces the one in this home and the actor is republished so other
|
|
@@ -0,0 +1,129 @@
|
|
|
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 — accounts on this server</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
|
+
p.lede { color: var(--dim); margin-top: 0; }
|
|
13
|
+
label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
|
|
14
|
+
input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
|
|
15
|
+
border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
|
|
16
|
+
.hint { color: var(--dim); font-size: .95rem; }
|
|
17
|
+
.primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
|
|
18
|
+
padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
|
|
19
|
+
.primary[disabled] { opacity: .5; cursor: not-allowed; }
|
|
20
|
+
button { font: inherit; cursor: pointer; }
|
|
21
|
+
[hidden] { display: none !important; }
|
|
22
|
+
table { border-collapse: collapse; width: 100%; margin: 1.2rem 0; }
|
|
23
|
+
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
|
|
24
|
+
th { font-weight: 600; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<h1>Accounts on this server</h1>
|
|
29
|
+
<p class="lede">Every name this server answers for: accounts whose identity lives here, and
|
|
30
|
+
accounts that only use it as their gateway. Sign in as the server's admin to see them.</p>
|
|
31
|
+
|
|
32
|
+
<form id="roster-form">
|
|
33
|
+
<p class="field">
|
|
34
|
+
<label for="roster-issuer">Your identity provider</label>
|
|
35
|
+
<input type="url" id="roster-issuer" autocomplete="off" value="https://solidcommunity.net">
|
|
36
|
+
</p>
|
|
37
|
+
<p class="actions">
|
|
38
|
+
<button type="submit" id="roster-signin" class="primary">Sign in & list accounts</button>
|
|
39
|
+
</p>
|
|
40
|
+
<p class="hint" id="roster-note" hidden></p>
|
|
41
|
+
</form>
|
|
42
|
+
|
|
43
|
+
<table id="roster-table" hidden>
|
|
44
|
+
<thead>
|
|
45
|
+
<tr><th>Account</th><th>Kind</th><th>Identity</th><th>Pod</th><th>Remove</th></tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody id="roster-rows"></tbody>
|
|
48
|
+
</table>
|
|
49
|
+
|
|
50
|
+
<script src="/solid-client-authn.bundle.js"></script>
|
|
51
|
+
<script>
|
|
52
|
+
const $ = (id) => document.getElementById(id);
|
|
53
|
+
const note = (text) => { const n = $('roster-note'); n.hidden = !text; n.textContent = text || ''; };
|
|
54
|
+
|
|
55
|
+
$('roster-issuer').addEventListener('input', () => {
|
|
56
|
+
$('roster-signin').disabled = !/^https?:\/\/\S+/.test($('roster-issuer').value.trim());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
$('roster-form').addEventListener('submit', (ev) => {
|
|
60
|
+
ev.preventDefault();
|
|
61
|
+
const auth = window.solidClientAuthentication;
|
|
62
|
+
if (!auth) { note('the sign-in library did not load — reload and try again'); return; }
|
|
63
|
+
auth.login({
|
|
64
|
+
oidcIssuer: $('roster-issuer').value.trim(),
|
|
65
|
+
redirectUrl: location.origin + '/admin',
|
|
66
|
+
clientName: 'FediPod admin',
|
|
67
|
+
}).catch((e) => note('sign-in failed to start: ' + e.message));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Back from the identity provider: read the roster with the proven login.
|
|
71
|
+
(async () => {
|
|
72
|
+
const auth = window.solidClientAuthentication;
|
|
73
|
+
if (!auth) return;
|
|
74
|
+
const info = await auth.handleIncomingRedirect().catch(() => null);
|
|
75
|
+
if (!info?.isLoggedIn) return;
|
|
76
|
+
|
|
77
|
+
// Signed fetches build a DPoP proof from the URL, so it must be absolute.
|
|
78
|
+
const load = async () => {
|
|
79
|
+
let res;
|
|
80
|
+
try { res = await auth.fetch(location.origin + '/api/roster'); }
|
|
81
|
+
catch (e) { note('the roster request failed: ' + e.message); return; }
|
|
82
|
+
const d = await res.json().catch(() => ({}));
|
|
83
|
+
if (res.status === 403) { note('signed in as ' + info.webId + ', which is not this server’s admin'); return; }
|
|
84
|
+
if (res.status === 501) { note('this server names no admin — set FEDIPOD_ADMIN_WEBID and redeploy'); return; }
|
|
85
|
+
if (res.status !== 200) { note('roster unavailable: ' + (d.error || 'HTTP ' + res.status)); return; }
|
|
86
|
+
const rows = $('roster-rows');
|
|
87
|
+
rows.textContent = '';
|
|
88
|
+
for (const a of d.accounts || []) {
|
|
89
|
+
const tr = document.createElement('tr');
|
|
90
|
+
const cell = (child) => { const td = document.createElement('td'); td.append(child); tr.append(td); };
|
|
91
|
+
cell(a.address);
|
|
92
|
+
cell(a.kind);
|
|
93
|
+
cell(a.fronted ? 'lives here' : 'gateway only');
|
|
94
|
+
const pod = document.createElement('a');
|
|
95
|
+
pod.href = a.podHome; pod.textContent = new URL(a.podHome).host;
|
|
96
|
+
cell(pod);
|
|
97
|
+
const rm = document.createElement('button');
|
|
98
|
+
rm.type = 'button'; rm.textContent = 'Remove';
|
|
99
|
+
rm.onclick = () => revoke(a);
|
|
100
|
+
cell(rm);
|
|
101
|
+
rows.append(tr);
|
|
102
|
+
}
|
|
103
|
+
$('roster-table').hidden = false;
|
|
104
|
+
note((d.accounts || []).length + ' account(s) on ' + d.host);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const revoke = async (a) => {
|
|
108
|
+
if (!confirm('Remove ' + a.address + ' from this server? The name stops resolving here; nothing on their pod is touched.')) return;
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
res = await auth.fetch(location.origin + '/api/revoke', {
|
|
112
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ handle: a.handle }),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) { note('the remove request failed: ' + e.message); return; }
|
|
116
|
+
const d = await res.json().catch(() => ({}));
|
|
117
|
+
if (res.status !== 200) { note('remove refused: ' + (d.error || 'HTTP ' + res.status)); return; }
|
|
118
|
+
if (!d.removed) { note(a.address + ' stays: ' + d.reason); return; }
|
|
119
|
+
await load();
|
|
120
|
+
note('removed ' + a.address);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
note('signed in as ' + info.webId + ' — reading the roster…');
|
|
124
|
+
await load();
|
|
125
|
+
})();
|
|
126
|
+
</script>
|
|
127
|
+
<i>(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
package/web/front/run.html
CHANGED
|
@@ -114,7 +114,8 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
|
|
|
114
114
|
const n = $('run-note');
|
|
115
115
|
n.hidden = false;
|
|
116
116
|
n.textContent = 'signed in as ' + info.webId + ' — asking the server…';
|
|
117
|
-
|
|
117
|
+
// The signed fetch builds a DPoP proof from the URL, so it must be absolute.
|
|
118
|
+
const res = await auth.fetch(location.origin + '/api/agent', {
|
|
118
119
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
119
120
|
body: JSON.stringify({ action: p.action, podBase: p.podBase }),
|
|
120
121
|
}).catch(() => null);
|