fedipod 0.13.0 → 0.14.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.
@@ -0,0 +1,257 @@
1
+ // fediacct.mjs — the agent's connections to fediverse accounts the owner holds
2
+ // on OTHER servers. The agent is an API client of an existing Mastodon-API
3
+ // account, the same relationship a Mastodon app has to this agent, and the same
4
+ // one atproto.mjs has to a Bluesky account. Nothing here is a second identity:
5
+ // no key, no pod, nothing published.
6
+ //
7
+ // One credential per account at AP_HOME/fediaccts/<id>.json (0600, atomic,
8
+ // stamped with the actor it was connected for — the keys.json rules). A file
9
+ // stamped for someone else is skipped rather than fatal, so one stray record
10
+ // cannot cost the owner the whole roster.
11
+ //
12
+ // An access token here is full access to that account on that server, so it
13
+ // never reaches pod state: config.json gets the handle and the host, nothing
14
+ // more.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { writeJsonAtomic } from './home.mjs';
20
+ import { safeFetch, retryAfterMs } from './safefetch.mjs';
21
+
22
+ const DIR = 'fediaccts';
23
+ const APPS = '_apps';
24
+ const SCOPES = 'read write';
25
+ const STATE_TTL_MS = 10 * 60_000;
26
+ const CLIENT_NAME = 'FediPod';
27
+
28
+ // The id becomes a file name, and both halves come from a remote server's own
29
+ // answer. `--handle ../escape` is the shape of bug this project has already
30
+ // paid for once, so the result is checked rather than trusted.
31
+ export function safeId(user, host) {
32
+ const id = `${String(user || '').trim()}@${String(host || '').trim()}`
33
+ .toLowerCase().replace(/[^a-z0-9.@_-]/g, '-');
34
+ if (!/^[a-z0-9][a-z0-9.@_-]{0,80}$/.test(id) || id.includes('..')) return null;
35
+ return id;
36
+ }
37
+
38
+ // A host as we will address it: bare authority, no scheme, no path, no case.
39
+ export function cleanHost(host) {
40
+ const raw = String(host || '').trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
41
+ return /^[a-z0-9.-]+(:\d+)?$/i.test(raw) ? raw.toLowerCase() : null;
42
+ }
43
+
44
+ export class FediAccounts {
45
+ constructor({ localDir, actorId = null, log = console.log, fetcher = null }) {
46
+ this.dir = path.join(localDir, DIR);
47
+ this.actorId = actorId;
48
+ this.log = log;
49
+ // Injectable for tests; the default is the politeness stack, which also
50
+ // refuses private addresses — a connected account is on the public web.
51
+ this.fetcher = fetcher || ((url, init) => safeFetch(url, init));
52
+ this.pausedUntil = new Map(); // host → epoch ms
53
+ this.pending = new Map(); // state nonce → { host, redirectUri, at }
54
+ }
55
+
56
+ // ---- records ----
57
+
58
+ _path(id) { return path.join(this.dir, `${id}.json`); }
59
+
60
+ ids() {
61
+ let names = [];
62
+ try { names = fs.readdirSync(this.dir); } catch { return []; }
63
+ return names.filter(n => n.endsWith('.json') && !n.startsWith('_')).map(n => n.slice(0, -5));
64
+ }
65
+
66
+ read(id) {
67
+ let rec;
68
+ try { rec = JSON.parse(fs.readFileSync(this._path(id), 'utf8')); } catch { return null; }
69
+ if (rec?.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
70
+ this.log(`fediaccts/${id}.json belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
71
+ return null;
72
+ }
73
+ return rec;
74
+ }
75
+
76
+ write(rec) {
77
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
78
+ writeJsonAtomic(this._path(rec.id), rec);
79
+ }
80
+
81
+ list() { return this.ids().map(id => this.read(id)).filter(Boolean); }
82
+
83
+ connected() { return this.list().some(r => r.token && r.enabled !== false); }
84
+
85
+ // What may be written to pod state: who, where, and whether it is polled.
86
+ roster() {
87
+ return this.list().map(r => ({
88
+ id: r.id, handle: r.handle, host: r.host,
89
+ addedAt: r.addedAt || null, enabled: r.enabled !== false,
90
+ }));
91
+ }
92
+
93
+ status() {
94
+ return this.list().map(r => ({
95
+ id: r.id, handle: r.handle, host: r.host,
96
+ enabled: r.enabled !== false,
97
+ needsReconnect: !!r.needsReconnect,
98
+ cooldownFor: Math.max(0, Math.round(((this.pausedUntil.get(r.host) || 0) - Date.now()) / 1000)),
99
+ }));
100
+ }
101
+
102
+ setEnabled(id, on) {
103
+ const rec = this.read(id);
104
+ if (!rec) return null;
105
+ this.write({ ...rec, enabled: !!on });
106
+ return this.roster().find(r => r.id === id) || null;
107
+ }
108
+
109
+ remove(id) {
110
+ if (!this.read(id)) return false;
111
+ try { fs.rmSync(this._path(id)); } catch { return false; }
112
+ this.log(`Fediverse account disconnected: ${id}`);
113
+ return true;
114
+ }
115
+
116
+ // ---- HTTP ----
117
+
118
+ async _fetch(host, url, init) {
119
+ const left = (this.pausedUntil.get(host) || 0) - Date.now();
120
+ if (left > 0) throw new Error(`${host} asked us to back off — ${Math.ceil(left / 1000)}s left`);
121
+ const res = await this.fetcher(url, init);
122
+ if (res.status === 429 || res.status === 503) {
123
+ const ms = retryAfterMs(res) ?? 60_000;
124
+ this.pausedUntil.set(host, Date.now() + ms);
125
+ this.log(`${host} answered ${res.status} — pausing for ${Math.round(ms / 1000)}s`);
126
+ }
127
+ return res;
128
+ }
129
+
130
+ async _json(res, host) {
131
+ const body = await res.json().catch(() => ({}));
132
+ if (res.status >= 400) {
133
+ const err = new Error(body.error_description || body.error || `${host} answered ${res.status}`);
134
+ err.status = res.status;
135
+ throw err;
136
+ }
137
+ return body;
138
+ }
139
+
140
+ // ---- the OAuth dance, as the client ----
141
+
142
+ // One app registration per host, remembered. The redirect_uri is part of what
143
+ // was registered, so an agent that moved to another port re-registers rather
144
+ // than sending the server a redirect it will refuse.
145
+ async appFor(host, redirectUri) {
146
+ const file = path.join(this.dir, APPS, `${host}.json`);
147
+ try {
148
+ const app = JSON.parse(fs.readFileSync(file, 'utf8'));
149
+ if (app.redirectUri === redirectUri && app.clientId && app.clientSecret) return app;
150
+ } catch { /* not registered here yet */ }
151
+ const res = await this._fetch(host, `https://${host}/api/v1/apps`, {
152
+ method: 'POST',
153
+ headers: { 'content-type': 'application/json' },
154
+ body: JSON.stringify({
155
+ client_name: CLIENT_NAME, redirect_uris: redirectUri,
156
+ scopes: SCOPES, website: 'https://github.com/jeff-zucker/FediPod',
157
+ }),
158
+ });
159
+ const body = await this._json(res, host);
160
+ if (!body.client_id || !body.client_secret) throw new Error(`${host} registered no client`);
161
+ const app = { host, redirectUri, clientId: body.client_id, clientSecret: body.client_secret };
162
+ fs.mkdirSync(path.join(this.dir, APPS), { recursive: true, mode: 0o700 });
163
+ writeJsonAtomic(file, app);
164
+ return app;
165
+ }
166
+
167
+ // Step one: where to send the browser. The state nonce is single-use and
168
+ // short-lived — without one, any page the owner visits could hand our
169
+ // callback somebody else's authorization code and bind THEIR account here.
170
+ async begin({ host, redirectUri }) {
171
+ const h = cleanHost(host);
172
+ if (!h) throw new Error('that is not a server address');
173
+ const app = await this.appFor(h, redirectUri);
174
+ const state = crypto.randomBytes(24).toString('hex');
175
+ const now = Date.now();
176
+ for (const [k, v] of this.pending) if (now - v.at > STATE_TTL_MS) this.pending.delete(k);
177
+ this.pending.set(state, { host: h, redirectUri, at: now });
178
+ const u = new URL(`https://${h}/oauth/authorize`);
179
+ u.searchParams.set('client_id', app.clientId);
180
+ u.searchParams.set('redirect_uri', redirectUri);
181
+ u.searchParams.set('response_type', 'code');
182
+ u.searchParams.set('scope', SCOPES);
183
+ u.searchParams.set('state', state);
184
+ return { url: u.href, state, host: h };
185
+ }
186
+
187
+ // Step two: the code comes back. Exchange it, ask the server who we now are,
188
+ // and only then write anything.
189
+ async complete({ state, code }) {
190
+ const pend = this.pending.get(state);
191
+ if (!pend || Date.now() - pend.at > STATE_TTL_MS) throw new Error('that sign-in has expired — start again');
192
+ this.pending.delete(state);
193
+ const { host, redirectUri } = pend;
194
+ const app = await this.appFor(host, redirectUri);
195
+ const res = await this._fetch(host, `https://${host}/oauth/token`, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify({
199
+ grant_type: 'authorization_code', code, redirect_uri: redirectUri,
200
+ client_id: app.clientId, client_secret: app.clientSecret, scope: SCOPES,
201
+ }),
202
+ });
203
+ const tok = await this._json(res, host);
204
+ if (!tok.access_token) throw new Error(`${host} returned no token`);
205
+ const me = await this.whoami(host, tok.access_token);
206
+ const id = safeId(me.username, host);
207
+ if (!id) throw new Error(`${host} answered with a name we cannot file safely`);
208
+ this.write({
209
+ id, host, token: tok.access_token, scope: tok.scope || SCOPES,
210
+ handle: `@${me.username}@${host}`, acct: me.acct || me.username,
211
+ accountId: String(me.id), actorUrl: me.url || null,
212
+ name: me.display_name || me.username, icon: me.avatar || null,
213
+ addedAt: new Date().toISOString(), enabled: true,
214
+ ...(this.actorId ? { mintedFor: this.actorId } : {}),
215
+ });
216
+ this.log(`Fediverse account connected: @${me.username}@${host}`);
217
+ return this.roster().find(r => r.id === id);
218
+ }
219
+
220
+ async whoami(host, token) {
221
+ const res = await this._fetch(host, `https://${host}/api/v1/accounts/verify_credentials`, {
222
+ headers: { authorization: `Bearer ${token}` },
223
+ });
224
+ const me = await this._json(res, host);
225
+ if (!me.username) throw new Error(`${host} did not say who we are`);
226
+ return me;
227
+ }
228
+
229
+ // ---- authenticated calls on behalf of one connected account ----
230
+
231
+ // Returns the Response, so a caller can read rate-limit headers. A 401 means
232
+ // the token was revoked on the far side: the account is marked rather than
233
+ // deleted, because the owner should be told rather than quietly dropped.
234
+ async api(id, pathname, init = {}) {
235
+ const rec = this.read(id);
236
+ if (!rec?.token) throw new Error(`no connected account ${id}`);
237
+ const url = pathname.startsWith('https://') ? pathname : `https://${rec.host}${pathname}`;
238
+ const res = await this._fetch(rec.host, url, {
239
+ ...init,
240
+ headers: { ...(init.headers || {}), authorization: `Bearer ${rec.token}` },
241
+ });
242
+ if (res.status === 401) {
243
+ if (!rec.needsReconnect) this.write({ ...rec, needsReconnect: true });
244
+ const err = new Error(`${rec.handle} no longer accepts our token — reconnect it`);
245
+ err.status = 401;
246
+ throw err;
247
+ }
248
+ if (rec.needsReconnect && res.status < 400) this.write({ ...rec, needsReconnect: false });
249
+ return res;
250
+ }
251
+
252
+ async apiJson(id, pathname, init = {}) {
253
+ const rec = this.read(id);
254
+ const res = await this.api(id, pathname, init);
255
+ return this._json(res, rec?.host || id);
256
+ }
257
+ }
package/lib/mastoapi.mjs CHANGED
@@ -327,6 +327,76 @@ export class MastoApi {
327
327
  } catch (e) { return send(422, { error: e.message }); }
328
328
  }
329
329
 
330
+ // Which account acts: the one that saw the post, and the first of them when
331
+ // several did. Undo inverts that — a like fans IN to one account, an unlike
332
+ // fans OUT to every account holding one, because a stray like left behind
333
+ // after the owner asked for it to go is the worse failure.
334
+ async acctAction(send, s, verb) {
335
+ const accounts = this.agent.fediaccts;
336
+ const held = s.sourceAccts || [];
337
+ if (!accounts || !held.length) {
338
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
339
+ }
340
+ const flag = verb.endsWith('favourite') ? 'favourited' : 'reblogged';
341
+ const undo = verb.startsWith('un');
342
+ const targets = (undo ? held.filter(v => v[flag]) : [held[0]]).filter(v => v?.remoteId);
343
+ if (!targets.length) return send(200, this.status(s));
344
+ try {
345
+ const acted = new Set();
346
+ for (const v of targets) {
347
+ await accounts.api(v.acct,
348
+ `/api/v1/statuses/${encodeURIComponent(v.remoteId)}/${verb}`, { method: 'POST' });
349
+ acted.add(v.acct);
350
+ }
351
+ const next = held.map(v => (acted.has(v.acct) ? { ...v, [flag]: !undo } : v));
352
+ return send(200, this.status(this.store.updateStatus(s.noteId, { sourceAccts: next }) || s));
353
+ } catch (e) { return send(e.status === 401 ? 401 : 422, { error: e.message }); }
354
+ }
355
+
356
+ // The reply exists only on that account's server, so the row added here is
357
+ // its one local copy — the same shape bskyReply uses for the same reason.
358
+ async acctReply(send, body, parent, visibility) {
359
+ const accounts = this.agent.fediaccts;
360
+ const held = (parent.sourceAccts || [])[0];
361
+ if (!accounts || !held?.remoteId) {
362
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
363
+ }
364
+ if (visibility !== 'public' && visibility !== 'unlisted') {
365
+ return send(422, { error: 'a reply from a connected account is public — pick public or unlisted' });
366
+ }
367
+ if (body.scheduled_at) return send(422, { error: 'a reply from a connected account cannot be scheduled' });
368
+ if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
369
+ return send(422, { error: 'images on a reply from a connected account are not supported' });
370
+ }
371
+ try {
372
+ const out = await accounts.apiJson(held.acct, '/api/v1/statuses', {
373
+ method: 'POST',
374
+ headers: { 'content-type': 'application/json' },
375
+ body: JSON.stringify({
376
+ status: body.status, in_reply_to_id: held.remoteId, visibility,
377
+ ...(body.spoiler_text ? { spoiler_text: String(body.spoiler_text) } : {}),
378
+ }),
379
+ });
380
+ if (!out?.uri) return send(502, { error: 'that server accepted the reply but did not say where it is' });
381
+ const rec = accounts.read(held.acct);
382
+ const actor = out.account?.uri || out.account?.url || rec?.actorUrl;
383
+ if (actor && !this.store.getActors()[actor]) {
384
+ this.store.cacheActor(actor, {
385
+ name: out.account?.display_name || rec?.name,
386
+ preferredUsername: out.account?.username || rec?.acct, type: 'Person',
387
+ });
388
+ }
389
+ this.store.addStatus({
390
+ noteId: out.uri, actor, inReplyTo: parent.noteId,
391
+ content: sanitizeHtml(out.content || ''),
392
+ published: out.created_at || new Date().toISOString(), kind: 'acct',
393
+ sourceAccts: [{ acct: held.acct, remoteId: String(out.id) }],
394
+ ...(out.url && out.url !== out.uri ? { link: out.url } : {}),
395
+ });
396
+ return send(200, this.status(this.store.getStatuses().find(x => x.noteId === out.uri)));
397
+ } catch (e) { return send(422, { error: e.message }); }
398
+ }
399
+
330
400
  status(s, { all } = {}) {
331
401
  const replies = (all || this.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
332
402
  return {
@@ -339,7 +409,11 @@ export class MastoApi {
339
409
  edited_at: s.editedAt || null,
340
410
  uri: s.noteId, url: s.link || s.noteId,
341
411
  replies_count: replies, reblogs_count: 0, favourites_count: 0,
342
- favourited: !!s.favourited, reblogged: !!s.reblogged,
412
+ // True when ANY of the owner's accounts holds it. The flag is really
413
+ // what the next tap will do: an empty star on a post one account has
414
+ // already liked invites a second outward like from a second identity.
415
+ favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
416
+ reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
343
417
  muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
344
418
  content: s.content || '',
345
419
  reblog: null, application: null,
@@ -807,6 +881,10 @@ export class MastoApi {
807
881
  // — the parent is not an AP object, so there is no AP note to make.
808
882
  const bskyParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'bsky');
809
883
  if (bskyParent) return this.bskyReply(send, body, bskyParent, visibility);
884
+ // A reply to a post a connected account brought us goes out from that
885
+ // account, where the conversation already is.
886
+ const acctParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'acct');
887
+ if (acctParent) return this.acctReply(send, body, acctParent, visibility);
810
888
  // Private and direct posts live in an owner-only pod container — and
811
889
  // only on a pod that provably enforces it.
812
890
  if (visibility === 'private' || visibility === 'direct') {
@@ -888,8 +966,24 @@ export class MastoApi {
888
966
  const noteUrl = this.store.urlFor(mStatus[1]);
889
967
  const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
890
968
  if (!s) return send(404, { error: 'Record not found' });
891
- if (s.actor !== this.urls.actor) return send(403, { error: 'not your status' });
969
+ // A post the owner wrote on a connected account is theirs to delete too.
970
+ // It is deleted where it lives — an AP Delete of ours would address an
971
+ // object that was never ours to speak for.
972
+ const onAcct = s.actor !== this.urls.actor
973
+ ? (this.agent.fediaccts?.list() || []).find(r => r.actorUrl && r.actorUrl === s.actor)
974
+ : null;
975
+ if (s.actor !== this.urls.actor && !onAcct) return send(403, { error: 'not your status' });
892
976
  const rendered = this.status(s);
977
+ if (onAcct) {
978
+ const held = (s.sourceAccts || []).find(v => v.acct === onAcct.id);
979
+ if (!held?.remoteId) return send(422, { error: `we do not know where ${onAcct.handle} keeps that post` });
980
+ try {
981
+ await this.agent.fediaccts.api(onAcct.id,
982
+ `/api/v1/statuses/${encodeURIComponent(held.remoteId)}`, { method: 'DELETE' });
983
+ } catch (e) { return send(502, { error: e.message }); }
984
+ this.store.removeStatus(s.noteId);
985
+ return send(200, { ...rendered, text: s.content || '' });
986
+ }
893
987
  // 502, because the refusal is the pod's: the client asked correctly and
894
988
  // the post is still up. Reporting 200 here is what let a deleted post
895
989
  // stay publicly readable with nothing to show for it.
@@ -1172,6 +1266,11 @@ export class MastoApi {
1172
1266
  return send(200, this.status(patch ? (this.store.updateStatus(s.noteId, patch) || s) : s));
1173
1267
  } catch (e) { return send(422, { error: e.message }); }
1174
1268
  }
1269
+ // A post that reached us through a connected account is not ours to
1270
+ // address as the pod actor. Our own posts and our own inbox's timeline
1271
+ // keep the AP path even when a connected account also saw them: where
1272
+ // the pod identity has standing, it is the one that acts.
1273
+ if (s.kind === 'acct') return this.acctAction(send, s, mAction[2]);
1175
1274
  const updated = await social[mAction[2]](this.agent, s);
1176
1275
  return send(200, this.status(updated || s));
1177
1276
  }
package/lib/store.mjs CHANGED
@@ -343,7 +343,8 @@ export class PodStore {
343
343
  }
344
344
  addStatus(s) {
345
345
  const all = this.getStatuses();
346
- if (all.some(x => x.noteId === s.noteId)) return;
346
+ const at = all.findIndex(x => x.noteId === s.noteId);
347
+ if (at >= 0) return this._mergeStatus(all, at, s);
347
348
  // Same reason as cacheActor: statuses.json is serialized whole on every
348
349
  // change, and remote content is only bounded by the 5 MB fetch ceiling.
349
350
  if (typeof s.content === 'string' && s.content.length > MAX_CONTENT) {
@@ -352,6 +353,30 @@ export class PodStore {
352
353
  all.unshift(s);
353
354
  this.write('statuses.json', all.slice(0, 1000));
354
355
  this.onEvent?.('status', s); // streaming subscribers
356
+ return { added: true, merged: false, status: s };
357
+ }
358
+
359
+ // The same post arrives twice when two of the owner's accounts follow its
360
+ // author, and the second arrival is the only record that the other one saw
361
+ // it. Merged in place — the per-kind prune tails take the tail to be the
362
+ // oldest — and with no stream event, which would show the post twice.
363
+ _mergeStatus(all, at, s) {
364
+ const row = all[at];
365
+ const known = new Set((row.sourceAccts || []).map(v => v.acct));
366
+ const fresh = (s.sourceAccts || []).filter(v => v && !known.has(v.acct));
367
+ // Only our own verified intake may raise a row's kind. A general ladder
368
+ // would let a source that merely SAW a post promote a stranger's mention
369
+ // into the home timeline, which is the route tagfeed had to close.
370
+ const first = (k) => k === 'post' || k === 'timeline';
371
+ const raise = first(s.kind) && !first(row.kind);
372
+ if (!fresh.length && !raise) return { added: false, merged: false, status: row };
373
+ all[at] = {
374
+ ...row,
375
+ ...(fresh.length ? { sourceAccts: [...(row.sourceAccts || []), ...fresh] } : {}),
376
+ ...(raise ? { kind: s.kind, ...(s.slug ? { slug: s.slug } : {}) } : {}),
377
+ };
378
+ this.write('statuses.json', all);
379
+ return { added: false, merged: true, status: all[at] };
355
380
  }
356
381
  updateStatus(noteId, patch) {
357
382
  const all = this.getStatuses();
package/lib/wire.mjs CHANGED
@@ -243,8 +243,8 @@ ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
243
243
  <h1>${esc(name)}</h1>
244
244
  <p class="address">${esc(address)}</p>
245
245
  ${summary ? `<div>${summary}</div>` : ''}
246
- <p>This is ${what} on the fediverse. To follow it, paste the address above
247
- into the search box of Mastodon or any fediverse app — or use the form.</p>
246
+ <p>This is ${what} on the Fediverse. To follow it, paste the address above
247
+ into the search box of Mastodon or any Fediverse app — or use the form.</p>
248
248
  <form id="follow">
249
249
  <label for="server">your server</label>
250
250
  <input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",