fedipod 0.19.0 → 1.2.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/README.md +104 -134
- package/architecture.md +41 -0
- package/bin/fedipod.mjs +45 -2253
- package/browser.svg +72 -0
- package/cli.md +0 -4
- package/gateway.md +160 -0
- package/groups.md +0 -6
- package/gui.md +7 -11
- package/installed-agent.md +97 -0
- package/lib/{c2s.mjs → client/c2s.mjs} +8 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +124 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +141 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +196 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/{embed.mjs → server/embed.mjs} +131 -22
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +16 -2
- package/run-agent.mjs +33 -25
- package/scripts/build-app.mjs +4 -0
- package/scripts/check-pod-calls.mjs +8 -2
- package/scripts/refresh-contexts.mjs +39 -0
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +12 -12
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/sw.js +21684 -5415
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/sw.js +21684 -5415
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
package/run-agent.mjs
CHANGED
|
@@ -28,28 +28,29 @@ import os from 'node:os';
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { pathToFileURL } from 'node:url';
|
|
30
30
|
|
|
31
|
-
import { PodStore } from './lib/store.mjs';
|
|
32
|
-
import { apRoot, rootOf, writeJsonAtomic } from './lib/home.mjs';
|
|
33
|
-
import { ensureTrustedTls } from './lib/certs.mjs';
|
|
34
|
-
import { storageFor } from './lib/storage.mjs';
|
|
35
|
-
import { resolveKeys } from './lib/keys.mjs';
|
|
36
|
-
import { RemotePod } from './lib/remote.mjs';
|
|
37
|
-
import { Deliverer } from './lib/deliver.mjs';
|
|
38
|
-
import { Publisher } from './lib/publisher.mjs';
|
|
39
|
-
import { Intake } from './lib/intake.mjs';
|
|
40
|
-
import { TagFeed } from './lib/tagfeed.mjs';
|
|
41
|
-
import { ImportWorker } from './lib/import.mjs';
|
|
42
|
-
import { Atproto } from './lib/atproto.mjs';
|
|
43
|
-
import { FediAccounts } from './lib/fediacct.mjs';
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
31
|
+
import { PodStore } from './lib/core/store.mjs';
|
|
32
|
+
import { apRoot, rootOf, writeJsonAtomic } from './lib/device/home.mjs';
|
|
33
|
+
import { ensureTrustedTls } from './lib/device/certs.mjs';
|
|
34
|
+
import { storageFor } from './lib/core/storage.mjs';
|
|
35
|
+
import { resolveKeys } from './lib/core/keys.mjs';
|
|
36
|
+
import { RemotePod } from './lib/device/remote.mjs';
|
|
37
|
+
import { Deliverer } from './lib/core/deliver.mjs';
|
|
38
|
+
import { Publisher } from './lib/core/publisher/index.mjs';
|
|
39
|
+
import { Intake } from './lib/core/intake/index.mjs';
|
|
40
|
+
import { TagFeed } from './lib/connections/tagfeed.mjs';
|
|
41
|
+
import { ImportWorker } from './lib/connections/import.mjs';
|
|
42
|
+
import { Atproto } from './lib/connections/atproto.mjs';
|
|
43
|
+
import { FediAccounts } from './lib/connections/fediacct.mjs';
|
|
44
|
+
import { vaultsFor } from './lib/connections/vault.mjs';
|
|
45
|
+
import { AcctFeed } from './lib/connections/acctfeed.mjs';
|
|
46
|
+
import { BskyFeed } from './lib/connections/bskyfeed.mjs';
|
|
47
|
+
import { BskyGroup } from './lib/connections/bskygroup.mjs';
|
|
48
|
+
import { Lease } from './lib/core/lease.mjs';
|
|
49
|
+
import { startAdmin } from './lib/device/admin/index.mjs';
|
|
50
|
+
import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
|
|
51
|
+
import { pendingSteps } from './lib/device/migrate.mjs';
|
|
52
|
+
import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
|
|
53
|
+
import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
|
|
53
54
|
|
|
54
55
|
export class Agent {
|
|
55
56
|
constructor({ home, log }) {
|
|
@@ -344,13 +345,20 @@ export class Agent {
|
|
|
344
345
|
// The CSV-import worker: paced, resumable, armed only while active.
|
|
345
346
|
this.importer?.stop();
|
|
346
347
|
this.importer = new ImportWorker({ agent: this, log: this.log });
|
|
348
|
+
// Where the credentials for accounts held on OTHER servers are kept.
|
|
349
|
+
const vaults = vaultsFor({ embedded: this.embedded, home: this.home, store: this.store });
|
|
347
350
|
// The Bluesky connection, when one exists. Stamped to this actor; a
|
|
348
351
|
// credential connected for another identity is treated as absent.
|
|
349
|
-
this.atproto = new Atproto({
|
|
352
|
+
this.atproto = new Atproto({
|
|
353
|
+
localDir: this.home, actorId: this.urls.actor, log: this.log, vault: vaults.bluesky,
|
|
354
|
+
});
|
|
350
355
|
this.publisher.atproto = this.atproto;
|
|
351
356
|
// Fediverse accounts the owner holds elsewhere. Same custody rules as the
|
|
352
357
|
// Bluesky credential, and the same absence when stamped for someone else.
|
|
353
|
-
this.fediaccts = new FediAccounts({
|
|
358
|
+
this.fediaccts = new FediAccounts({
|
|
359
|
+
localDir: this.home, actorId: this.urls.actor, log: this.log,
|
|
360
|
+
vault: vaults.fedi, apps: vaults.apps,
|
|
361
|
+
});
|
|
354
362
|
this.acctfeed?.stop();
|
|
355
363
|
this.acctfeed = null;
|
|
356
364
|
this.bskyfeed?.stop();
|
|
@@ -773,7 +781,7 @@ export async function startAgent({
|
|
|
773
781
|
// Is a newer FediPod published? Once at boot and daily after; the answer
|
|
774
782
|
// rides /status and the record page offers the update.
|
|
775
783
|
const updateTick = async () => {
|
|
776
|
-
const { checkLatest } = await import('./lib/update.mjs');
|
|
784
|
+
const { checkLatest } = await import('./lib/device/update.mjs');
|
|
777
785
|
const u = await checkLatest();
|
|
778
786
|
if (u) {
|
|
779
787
|
agent.updateInfo = u;
|
package/scripts/build-app.mjs
CHANGED
|
@@ -32,6 +32,9 @@ const aliasPlugin = {
|
|
|
32
32
|
build.onResolve({ filter: /^node:fs$/ }, () => ({ path: shim('node-fs.mjs') }));
|
|
33
33
|
build.onResolve({ filter: /^web-push$/ }, () => ({ path: shim('web-push.mjs') }));
|
|
34
34
|
build.onResolve({ filter: /\/safefetch\.mjs$/ }, () => ({ path: shim('safefetch.mjs') }));
|
|
35
|
+
// The shapes are one .ttl file for both agents; Node reads it, the browser
|
|
36
|
+
// gets it through the text loader below.
|
|
37
|
+
build.onResolve({ filter: /shapes-text\.mjs$/ }, () => ({ path: shim('shapes-text.mjs') }));
|
|
35
38
|
const stub = new RegExp(`^(${STUBBED.map((s) => s.replace('/', '\\/')).join('|')})$`);
|
|
36
39
|
build.onResolve({ filter: stub }, (args) => ({ path: args.path, namespace: 'stub' }));
|
|
37
40
|
build.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => ({
|
|
@@ -48,6 +51,7 @@ export async function buildApp({ entry, out, minify = false, format = 'esm' } =
|
|
|
48
51
|
bundle: true, format, platform: 'browser', target: 'es2022',
|
|
49
52
|
outfile: out, sourcemap: true, minify, logLevel: 'silent', metafile: true,
|
|
50
53
|
plugins: [aliasPlugin],
|
|
54
|
+
loader: { '.ttl': 'text' },
|
|
51
55
|
define: { 'process.env.NODE_ENV': '"production"' },
|
|
52
56
|
banner: { js: fs.readFileSync(shim('prelude.js'), 'utf8') },
|
|
53
57
|
});
|
|
@@ -53,8 +53,14 @@ const podFiles = fs.readdirSync(POD).filter((f) => f.endsWith('.mjs'));
|
|
|
53
53
|
// The files that run the browser agent and the gateway. Node-only code is
|
|
54
54
|
// deliberately out of scope for this pass and must not fail the check.
|
|
55
55
|
const IN_SCOPE = [
|
|
56
|
-
'lib/
|
|
57
|
-
'lib/
|
|
56
|
+
'lib/core/social.mjs',
|
|
57
|
+
'lib/client/masto/index.mjs', 'lib/client/masto/accounts.mjs', 'lib/client/masto/media.mjs',
|
|
58
|
+
'lib/client/masto/statuses.mjs', 'lib/client/masto/timelines.mjs', 'lib/client/masto/render.mjs',
|
|
59
|
+
'lib/core/publisher/index.mjs', 'lib/core/publisher/collections.mjs', 'lib/core/publisher/restore.mjs',
|
|
60
|
+
'lib/core/publisher/notes.mjs', 'lib/core/publisher/questions.mjs',
|
|
61
|
+
'lib/core/intake/index.mjs', 'lib/core/intake/verify.mjs', 'lib/core/intake/notes.mjs',
|
|
62
|
+
'lib/core/intake/channel.mjs', 'lib/core/intake/group.mjs', 'lib/core/intake/activities.mjs',
|
|
63
|
+
'lib/gateway/gateway-core.mjs', 'lib/gateway/front-core.mjs',
|
|
58
64
|
'web/app/agent.mjs', 'web/app/signup.mjs', 'web/app/boot.mjs',
|
|
59
65
|
'web/app/admin-facade.mjs', 'web/app/keys-browser.mjs',
|
|
60
66
|
];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// refresh-contexts.mjs — re-copy the JSON-LD contexts from @fedify/vocab-runtime.
|
|
2
|
+
//
|
|
3
|
+
// Fedify keeps that set current against real fediverse traffic; we hold our own
|
|
4
|
+
// copies so nothing in lib/ depends on a transitive package's internals, and so
|
|
5
|
+
// what we serve for a stranger's document is a file in this repo.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/refresh-contexts.mjs
|
|
8
|
+
//
|
|
9
|
+
// Run it after a fedify upgrade, then check `git diff lib/core/contexts/`.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const src = path.join(root, 'node_modules/@fedify/vocab-runtime/src/contexts');
|
|
16
|
+
const dest = path.join(root, 'lib/core/contexts');
|
|
17
|
+
const module_ = fs.readFileSync(path.join(src, '..', 'contexts.ts'), 'utf8');
|
|
18
|
+
|
|
19
|
+
const idents = Object.fromEntries(
|
|
20
|
+
[...module_.matchAll(/import (\w+) from "\.\/contexts\/([^"]+)"/gu)].map((m) => [m[1], m[2]]),
|
|
21
|
+
);
|
|
22
|
+
const map = {};
|
|
23
|
+
for (const m of module_.matchAll(/"([^"]+)":\s*(\w+),/gu)) {
|
|
24
|
+
if (idents[m[2]]) map[m[1]] = idents[m[2]];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const file of Object.values(map)) fs.copyFileSync(path.join(src, file), path.join(dest, file));
|
|
28
|
+
fs.writeFileSync(path.join(dest, 'map.json'), `${JSON.stringify(map, null, 2)}\n`);
|
|
29
|
+
|
|
30
|
+
const names = Object.fromEntries(Object.keys(map).map((u, i) => [u, `ctx${i}`]));
|
|
31
|
+
const header = fs.readFileSync(path.join(dest, 'index.mjs'), 'utf8').split('\nimport ')[0];
|
|
32
|
+
let out = `${header}\n`;
|
|
33
|
+
for (const [url, file] of Object.entries(map)) out += `import ${names[url]} from './${file}' with { type: 'json' };\n`;
|
|
34
|
+
out += '\n/** URL → the context document itself. Nothing outside this map is ever resolved. */\nexport const CONTEXTS = {\n';
|
|
35
|
+
for (const url of Object.keys(map)) out += ` '${url}': ${names[url]},\n`;
|
|
36
|
+
out += '};\n';
|
|
37
|
+
fs.writeFileSync(path.join(dest, 'index.mjs'), out);
|
|
38
|
+
|
|
39
|
+
console.log(`refreshed ${Object.keys(map).length} contexts into lib/core/contexts/`);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// actors.js — the other actors on this machine: the picker, going to one,
|
|
2
|
+
// starting a stopped one, and the form for a new one.
|
|
3
|
+
|
|
4
|
+
// Somewhere to go, and nothing else. A stopped actor is left out — the link
|
|
5
|
+
// would land on nothing. The one you are on keeps its place in the row but
|
|
6
|
+
// offers only `app`, because its admin page is the page you are reading.
|
|
7
|
+
|
|
8
|
+
let actors = [];
|
|
9
|
+
|
|
10
|
+
async function renderOthers() {
|
|
11
|
+
const { json } = await api('/profiles');
|
|
12
|
+
actors = json?.identities || [];
|
|
13
|
+
const sel = $('actor-pick');
|
|
14
|
+
sel.textContent = '';
|
|
15
|
+
// The fediverse address, which is what the actor IS to everyone else, and the
|
|
16
|
+
// only form that stays distinct: two identities can share a local handle, but
|
|
17
|
+
// never a handle AND a pod. A stopped one has no address to report — nothing
|
|
18
|
+
// answered — so it falls back to the name it is filed under.
|
|
19
|
+
const label = (r) => (r.address || r.handle || r.name)
|
|
20
|
+
+ (r.mode && r.mode !== 'active' ? ` (${r.mode})` : '') + (r.mode ? '' : ' (stopped)');
|
|
21
|
+
const seen = {};
|
|
22
|
+
for (const r of actors) seen[label(r)] = (seen[label(r)] || 0) + 1;
|
|
23
|
+
|
|
24
|
+
for (const [i, r] of actors.entries()) {
|
|
25
|
+
const o = document.createElement('option');
|
|
26
|
+
o.value = String(i);
|
|
27
|
+
o.selected = !!r.current; // it opens showing where you already are
|
|
28
|
+
o.textContent = label(r) + (seen[label(r)] > 1 && r.port ? ` :${r.port}` : '');
|
|
29
|
+
sel.appendChild(o);
|
|
30
|
+
}
|
|
31
|
+
// The way to a NEW actor rides the same dropdown as the existing ones.
|
|
32
|
+
const add = document.createElement('option');
|
|
33
|
+
add.value = '__add';
|
|
34
|
+
add.textContent = '+ add a new account…';
|
|
35
|
+
sel.appendChild(add);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Going to an actor commits a navigation and, for a stopped one, a POST that
|
|
39
|
+
// boots its pod — so it must run once per choice. Pressing Enter on the select
|
|
40
|
+
// fires BOTH a keydown (handled below) and a native `change`, so without a
|
|
41
|
+
// guard a keyboard commit would start the actor twice.
|
|
42
|
+
let goingToActor = false;
|
|
43
|
+
const goToActor = async () => {
|
|
44
|
+
if (goingToActor) return;
|
|
45
|
+
if ($('actor-pick').value === '__add') { renderOthers(); openNewActor(); return; }
|
|
46
|
+
const r = actors[Number($('actor-pick').value)];
|
|
47
|
+
if (!r || r.current) return;
|
|
48
|
+
goingToActor = true;
|
|
49
|
+
try {
|
|
50
|
+
if (r.mode) { location.href = r.admin; return; }
|
|
51
|
+
$('actor-pick').disabled = true;
|
|
52
|
+
say(`starting ${r.name}`);
|
|
53
|
+
const started = await write('/start-actor', { name: r.name }, `${r.name} is up`);
|
|
54
|
+
$('actor-pick').disabled = false;
|
|
55
|
+
if (started?.url) location.href = `${started.url}admin/`;
|
|
56
|
+
else renderOthers(); // put the picker back on the current actor
|
|
57
|
+
} finally {
|
|
58
|
+
goingToActor = false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
// Choosing one goes to its record, as it always has.
|
|
62
|
+
//
|
|
63
|
+
// Arrowing is the one case that must not: on a closed select every Arrow
|
|
64
|
+
// keypress fires `change`, so a keyboard user browsing the list would be
|
|
65
|
+
// carried off to the first actor they passed — and for a stopped one that
|
|
66
|
+
// boots its pod. An arrow key arms a flag and the change it causes is ignored;
|
|
67
|
+
// Enter commits. A mouse never sets the flag, so clicking behaves as before.
|
|
68
|
+
let arrowing = false;
|
|
69
|
+
$('actor-pick').addEventListener('keydown', (ev) => {
|
|
70
|
+
if (['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(ev.key)) arrowing = true;
|
|
71
|
+
if (ev.key === 'Enter') { arrowing = false; goToActor(); }
|
|
72
|
+
});
|
|
73
|
+
$('actor-pick').addEventListener('pointerdown', () => { arrowing = false; });
|
|
74
|
+
$('actor-pick').addEventListener('change', () => {
|
|
75
|
+
if (!arrowing) goToActor();
|
|
76
|
+
arrowing = false;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Setting up a new actor asks for exactly what this page cannot already tell
|
|
80
|
+
// it: the pod and the account behind it, and its permanent name. Display name,
|
|
81
|
+
// bio and pictures are the client's job now — Phanpy's profile editor writes
|
|
82
|
+
// them through /api/v1/accounts/update_credentials. Where the private data goes
|
|
83
|
+
// is not a question either: it lands beside the credential, and `state --to`
|
|
84
|
+
// moves it afterwards.
|
|
85
|
+
|
|
86
|
+
const picked = (name) => document.querySelector(`input[name=${name}]:checked`)?.value;
|
|
87
|
+
|
|
88
|
+
function newActorRows() {
|
|
89
|
+
const mode = picked('newMode');
|
|
90
|
+
$('new-row-podname').hidden = mode !== 'new';
|
|
91
|
+
$('new-row-pod').hidden = mode !== 'existing';
|
|
92
|
+
}
|
|
93
|
+
for (const el of document.querySelectorAll('input[name=newMode]')) {
|
|
94
|
+
el.addEventListener('change', newActorRows);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// While the form is up the page IS the new actor's setup — so the record of
|
|
98
|
+
// the actor you came from goes away, list included. Which panes were showing
|
|
99
|
+
// is remembered rather than recomputed: whether Group and Inbox belong is a
|
|
100
|
+
// decision render() already made.
|
|
101
|
+
const RECORD_PANES = ['pane-identity', 'pane-group', 'pane-inbox', 'rail'];
|
|
102
|
+
let putBack = [];
|
|
103
|
+
|
|
104
|
+
function showNewActor(on) {
|
|
105
|
+
if (on) closePanels(); // nothing of the old actor left open behind it
|
|
106
|
+
document.body.classList.toggle('adding', on); // see body.adding in the CSS
|
|
107
|
+
$('new-actor-form').hidden = !on;
|
|
108
|
+
if (on) {
|
|
109
|
+
putBack = RECORD_PANES.filter(id => !$(id).hidden);
|
|
110
|
+
for (const id of putBack) $(id).hidden = true;
|
|
111
|
+
} else {
|
|
112
|
+
for (const id of putBack) $(id).hidden = false;
|
|
113
|
+
putBack = [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `add a new account` is the last item of the Local Actors dropdown; choosing
|
|
118
|
+
// it opens the form in place. Arriving with ?new does the same.
|
|
119
|
+
function openNewActor() {
|
|
120
|
+
showNewActor(true);
|
|
121
|
+
newActorRows();
|
|
122
|
+
$('new-handle').focus();
|
|
123
|
+
}
|
|
124
|
+
$('new-actor-cancel').addEventListener('click', () => { showNewActor(false); say('nothing changed'); });
|
|
125
|
+
|
|
126
|
+
$('new-actor-form').addEventListener('submit', async (ev) => {
|
|
127
|
+
ev.preventDefault();
|
|
128
|
+
const handle = $('new-handle').value.trim();
|
|
129
|
+
const answers = {
|
|
130
|
+
handle,
|
|
131
|
+
kind: picked('newKind'),
|
|
132
|
+
mode: picked('newMode'),
|
|
133
|
+
issuer: $('new-issuer').value.trim(),
|
|
134
|
+
email: $('new-email').value.trim(),
|
|
135
|
+
password: $('new-password').value,
|
|
136
|
+
};
|
|
137
|
+
if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
|
|
138
|
+
else answers.pod = $('new-pod').value.trim();
|
|
139
|
+
$('new-actor-go').disabled = true;
|
|
140
|
+
say(`setting up ${handle || 'the new actor'} — this takes a while`);
|
|
141
|
+
const r = await write('/new-actor', answers, `setting up ${handle}`);
|
|
142
|
+
$('new-actor-go').disabled = false;
|
|
143
|
+
if (r?.url) location.href = r.url; // its own page, where the progress is
|
|
144
|
+
});
|
|
145
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// common.js — what every admin page starts from: the element lookup, the
|
|
2
|
+
// door the page is served behind, and the two ways it talks to the agent.
|
|
3
|
+
// Loaded first by the record page and by the setup page.
|
|
4
|
+
|
|
5
|
+
const $ = (id) => document.getElementById(id);
|
|
6
|
+
// The door this page is served behind: nothing when the agent runs on this
|
|
7
|
+
// machine, `/app` when the identity is hosted by its own pod server. Read from
|
|
8
|
+
// the page's own address, so one build serves both.
|
|
9
|
+
const BASE = location.pathname.replace(/\/admin\/.*$/u, '');
|
|
10
|
+
// Every call carries `x-fedipod-page`. On the Node agent it is ignored — the
|
|
11
|
+
// Origin check there already refuses a page on somebody else's site. In the
|
|
12
|
+
// browser build the agent IS this origin's service worker, which cannot see
|
|
13
|
+
// Sec-Fetch-* and answers these routes with no password, so this header is the
|
|
14
|
+
// whole door: a cross-site form cannot set one, and a cross-origin fetch that
|
|
15
|
+
// sets one is stopped at a preflight the worker never grants. See sw-src.mjs.
|
|
16
|
+
const PAGE_HEADER = { 'x-fedipod-page': '1' };
|
|
17
|
+
const api = async (path, init) => {
|
|
18
|
+
const res = await fetch(BASE + path, { ...init, headers: { ...(init?.headers || {}), ...PAGE_HEADER } });
|
|
19
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
20
|
+
};
|
|
21
|
+
const postJson = (path, body) => api(path, {
|
|
22
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
|
|
23
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// connections.js — the accounts held elsewhere: the rail, the connect forms
|
|
2
|
+
// for Bluesky and for a fediverse account, and the migration aliases.
|
|
3
|
+
|
|
4
|
+
// The connect form lives in the floating window, like every other form here.
|
|
5
|
+
// The inbound transfer panel: the aliases live here now, not in the facts.
|
|
6
|
+
$('do-transfer-in').addEventListener('click', () => {
|
|
7
|
+
solWindow.show('transfer-in-form', 'Transfer an account here');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// The rail: one click hides the actions; on a phone it starts hidden.
|
|
11
|
+
function setRail(closed) {
|
|
12
|
+
$('rail').classList.toggle('closed', closed);
|
|
13
|
+
const t = $('rail-toggle');
|
|
14
|
+
t.setAttribute('aria-expanded', String(!closed));
|
|
15
|
+
t.querySelector('.rail-arrow').textContent = closed ? '»' : '«';
|
|
16
|
+
t.title = closed ? ' Show the action panel' : ' Hide the action panel';
|
|
17
|
+
}
|
|
18
|
+
$('rail-toggle').addEventListener('click', () => setRail(!$('rail').classList.contains('closed')));
|
|
19
|
+
if (matchMedia('(max-width: 47rem)').matches) setRail(true);
|
|
20
|
+
|
|
21
|
+
// One way in for both networks, because from the record they are the same
|
|
22
|
+
// thing: an account elsewhere whose timeline joins the one you read here.
|
|
23
|
+
$('ident-open').addEventListener('click', () => {
|
|
24
|
+
closePanels();
|
|
25
|
+
solWindow.show('ident-form', 'Connect an identity');
|
|
26
|
+
});
|
|
27
|
+
$('ident-pick-fedi').addEventListener('click', () => {
|
|
28
|
+
solWindow.show('fediacct-form', 'Connect a Fediverse account');
|
|
29
|
+
});
|
|
30
|
+
$('ident-pick-bsky').addEventListener('click', () => {
|
|
31
|
+
solWindow.show('bsky-form', 'Connect a Bluesky account');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let bskyBusy = false;
|
|
35
|
+
$('bsky-form').addEventListener('submit', async (ev) => {
|
|
36
|
+
ev.preventDefault();
|
|
37
|
+
if (bskyBusy) return;
|
|
38
|
+
bskyBusy = true;
|
|
39
|
+
try {
|
|
40
|
+
const r = await write('/atproto/connect', {
|
|
41
|
+
service: $('bsky-service').value.trim(),
|
|
42
|
+
identifier: $('bsky-identifier').value.trim(),
|
|
43
|
+
appPassword: $('bsky-password').value,
|
|
44
|
+
}, 'bluesky account connected');
|
|
45
|
+
if (r) {
|
|
46
|
+
$('bsky-password').value = '';
|
|
47
|
+
closePanels();
|
|
48
|
+
await load();
|
|
49
|
+
}
|
|
50
|
+
} finally { bskyBusy = false; }
|
|
51
|
+
});
|
|
52
|
+
// The sign-in happens at the other server, so this hands the browser over
|
|
53
|
+
// rather than taking a password. What comes back lands on /fediacct/callback,
|
|
54
|
+
// which this machine answers and nowhere else does.
|
|
55
|
+
let fediBusy = false;
|
|
56
|
+
$('fediacct-form').addEventListener('submit', async (ev) => {
|
|
57
|
+
ev.preventDefault();
|
|
58
|
+
if (fediBusy) return;
|
|
59
|
+
fediBusy = true;
|
|
60
|
+
try {
|
|
61
|
+
const r = await write('/fediacct/connect', { host: $('fediacct-host').value.trim() }, '');
|
|
62
|
+
if (r?.authorize) location.href = r.authorize;
|
|
63
|
+
} finally { fediBusy = false; }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Disconnecting Bluesky, pausing its feed and choosing its storage all live in
|
|
67
|
+
// the account's Options menu now (see optionsMenu). Connecting is still here.
|
|
68
|
+
// Adding resolves the old account on the agent side, so what lands in
|
|
69
|
+
// alsoKnownAs is its canonical id, not the string typed here.
|
|
70
|
+
// The accounts elsewhere this one may receive a Move from — the chips in the
|
|
71
|
+
// Transfer-an-account-here panel. Each entry is removable, behind a second
|
|
72
|
+
// click: servers still retrying a Move check the list, so removal is not a
|
|
73
|
+
// tidy-up.
|
|
74
|
+
function renderAliases() {
|
|
75
|
+
const box = $('alias-chips');
|
|
76
|
+
box.textContent = '';
|
|
77
|
+
for (const alias of config.aliases || []) {
|
|
78
|
+
let armed = false;
|
|
79
|
+
const rm = document.createElement('button');
|
|
80
|
+
rm.type = 'button';
|
|
81
|
+
rm.className = 'inline danger';
|
|
82
|
+
rm.textContent = '✕';
|
|
83
|
+
rm.title = ' Remove this alias';
|
|
84
|
+
rm.addEventListener('click', async () => {
|
|
85
|
+
if (!armed) {
|
|
86
|
+
armed = true;
|
|
87
|
+
rm.textContent = 'confirm ✕';
|
|
88
|
+
rm.title = ' Servers still retrying the Move check this alias — click again to remove it anyway';
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const r = await write('/alias', { remove: alias, confirm: true }, 'alias removed and the actor republished');
|
|
92
|
+
if (r) { config.aliases = r.aliases; render(); }
|
|
93
|
+
});
|
|
94
|
+
const chip = document.createElement('span');
|
|
95
|
+
chip.append(alias, ' ', rm);
|
|
96
|
+
box.append(chip, ' ');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let aliasBusy = false;
|
|
101
|
+
$('alias-add').addEventListener('click', async () => {
|
|
102
|
+
if (aliasBusy) return;
|
|
103
|
+
const v = $('alias-input').value.trim();
|
|
104
|
+
if (!v) { say('enter the old account first — @you@old.server', 'err'); return; }
|
|
105
|
+
aliasBusy = true;
|
|
106
|
+
try {
|
|
107
|
+
const r = await write('/alias', { add: v }, 'alias added and the actor republished');
|
|
108
|
+
if (r) { $('alias-input').value = ''; config.aliases = r.aliases; render(); }
|
|
109
|
+
} finally { aliasBusy = false; }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// gateway.js — the gateway row and its attach and detach forms, the inbox
|
|
2
|
+
// prune actions, and the load that starts the page.
|
|
3
|
+
|
|
4
|
+
const GATEWAY_CTL = $('gateway-ctl'); // held: it rides into a generated row
|
|
5
|
+
const GATEWAY_WORD = $('gateway-word');
|
|
6
|
+
const GW_OPEN_ATTACH = $('gateway-open-attach');
|
|
7
|
+
const GW_OPEN_DETACH = $('gateway-open-detach');
|
|
8
|
+
let gwState = null;
|
|
9
|
+
async function refreshGateway() {
|
|
10
|
+
const { status, json: g } = await api('/gateway');
|
|
11
|
+
if (status !== 200 || !g) return;
|
|
12
|
+
gwState = g;
|
|
13
|
+
GATEWAY_CTL.hidden = false;
|
|
14
|
+
if (g.configured) {
|
|
15
|
+
let host = g.url;
|
|
16
|
+
try { host = new URL(g.url).host; } catch { /* show it as-is */ }
|
|
17
|
+
const frontName = g.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1];
|
|
18
|
+
GATEWAY_WORD.textContent = frontName ? `${host} — publishing as @${frontName}@${host}` : host;
|
|
19
|
+
GW_OPEN_ATTACH.hidden = true;
|
|
20
|
+
GW_OPEN_DETACH.hidden = false;
|
|
21
|
+
} else {
|
|
22
|
+
GATEWAY_WORD.textContent = '';
|
|
23
|
+
GW_OPEN_ATTACH.hidden = false;
|
|
24
|
+
GW_OPEN_DETACH.hidden = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
|
|
28
|
+
function gwPreviews() {
|
|
29
|
+
$('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
|
|
30
|
+
try { $('gw-front-host').textContent = new URL($('gw-front').value.trim()).host; }
|
|
31
|
+
catch { /* not a URL yet — the placeholder host stands */ }
|
|
32
|
+
}
|
|
33
|
+
function gwShapeChanged() { gwPreviews(); gwCheck(); }
|
|
34
|
+
for (const r of document.querySelectorAll('input[name=gwShape]')) r.addEventListener('change', gwShapeChanged);
|
|
35
|
+
// Typing in the blank IS choosing that shape.
|
|
36
|
+
$('gw-name').addEventListener('focus', () => {
|
|
37
|
+
const r = document.querySelector('input[name=gwShape][value=front]');
|
|
38
|
+
if (!r.checked) { r.checked = true; gwShapeChanged(); }
|
|
39
|
+
});
|
|
40
|
+
GW_OPEN_ATTACH.addEventListener('click', () => {
|
|
41
|
+
gwShapeChanged();
|
|
42
|
+
solWindow.show('gateway-attach-form', 'Attach to a gateway');
|
|
43
|
+
});
|
|
44
|
+
GW_OPEN_DETACH.addEventListener('click', () => {
|
|
45
|
+
$('gw-detach-fronted').hidden = !gwState?.frontActor;
|
|
46
|
+
$('gw-detach-plain').hidden = !!gwState?.frontActor;
|
|
47
|
+
solWindow.show('gateway-detach-form', 'Detach from gateway');
|
|
48
|
+
});
|
|
49
|
+
// Live availability, asked through the agent (the front answers it without CORS).
|
|
50
|
+
let gwTimer = null;
|
|
51
|
+
function gwCheck() {
|
|
52
|
+
clearTimeout(gwTimer);
|
|
53
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
54
|
+
const name = $('gw-name').value.trim().toLowerCase();
|
|
55
|
+
$('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
|
|
56
|
+
if (gwShape() !== 'front' || !front || !name) return;
|
|
57
|
+
gwTimer = setTimeout(async () => {
|
|
58
|
+
const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
|
|
59
|
+
if (status !== 200 || !json) return;
|
|
60
|
+
$('gw-name-msg').textContent = json.available
|
|
61
|
+
? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
|
|
62
|
+
: (json.reason || 'that name is taken');
|
|
63
|
+
$('gw-name-msg').className = json.available ? 'hint' : 'warn';
|
|
64
|
+
}, 300);
|
|
65
|
+
}
|
|
66
|
+
$('gw-front').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
67
|
+
$('gw-name').addEventListener('input', () => { gwPreviews(); gwCheck(); });
|
|
68
|
+
$('gw-attach').addEventListener('click', async () => {
|
|
69
|
+
const front = $('gw-front').value.trim().replace(/\/+$/, '');
|
|
70
|
+
const fronted = gwShape() === 'front';
|
|
71
|
+
const name = $('gw-name').value.trim().toLowerCase();
|
|
72
|
+
if (fronted && !name) { say('give the handle you want at the gateway', 'err'); return; }
|
|
73
|
+
$('gw-attach').disabled = true;
|
|
74
|
+
// Pod-based sends no name: the door's label is the agent's business, not
|
|
75
|
+
// the user's, and the agent picks a free variant by itself.
|
|
76
|
+
const r = await write('/gateway',
|
|
77
|
+
{ action: 'attach', front, ...(fronted ? { handle: name, fronted: true } : {}) },
|
|
78
|
+
fronted ? 'attached — the agent is restarting to publish under the gateway handle; reload in a moment'
|
|
79
|
+
: 'attached — your mail now arrives through the gateway, filtered');
|
|
80
|
+
$('gw-attach').disabled = false;
|
|
81
|
+
if (r) { closePanels(); refreshGateway(); }
|
|
82
|
+
});
|
|
83
|
+
$('gw-detach').addEventListener('click', async () => {
|
|
84
|
+
const fronted = !!gwState?.frontActor;
|
|
85
|
+
const r = await write('/gateway', { action: 'forget' },
|
|
86
|
+
fronted ? 'detached — the agent is restarting under your pod\'s own name; reload in a moment'
|
|
87
|
+
: 'detached — the actor was republished advertising your pod\'s own inbox');
|
|
88
|
+
if (r) { closePanels(); refreshGateway(); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
$('inbox-keep').addEventListener('click', () => {
|
|
92
|
+
dismissed = true;
|
|
93
|
+
$('pane-inbox').hidden = true;
|
|
94
|
+
say('leaving it to the agent — it drains oldest first');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
$('inbox-prune').addEventListener('click', async () => {
|
|
98
|
+
const days = Number($('inbox-before').value);
|
|
99
|
+
const before = new Date(Date.now() - days * 86400_000).toISOString();
|
|
100
|
+
$('inbox-prune').disabled = true;
|
|
101
|
+
say(`discarding content older than ${days} days — this takes a while`);
|
|
102
|
+
const r = await write('/inbox/prune', { before }, 'done');
|
|
103
|
+
$('inbox-prune').disabled = false;
|
|
104
|
+
if (r) {
|
|
105
|
+
say(`applied ${r.applied} follow/unfollow/delete, discarded ${r.dropped + r.discarded} posts`
|
|
106
|
+
+ (r.failed ? `, ${r.failed} failed` : ''));
|
|
107
|
+
renderInbox();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
load();
|