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.
- package/README.md +12 -7
- package/bin/fedipod.mjs +4 -4
- package/groups.md +3 -3
- package/gui.md +28 -2
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +145 -3
- package/lib/bskygroup.mjs +1 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/mastoapi.mjs +101 -2
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +2 -2
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +31 -0
- package/scripts/verify-ui.mjs +7 -4
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
package/lib/fediacct.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
into the search box of Mastodon or any
|
|
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.
|
|
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",
|