fedipod 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/groups.md +1 -0
- package/lib/client/c2s.mjs +19 -5
- package/lib/client/masto/index.mjs +9 -1
- package/lib/client/masto/render.mjs +5 -3
- package/lib/client/masto/statuses.mjs +7 -4
- package/lib/client/oidc-auth.mjs +10 -3
- package/lib/core/as2.mjs +1 -1
- package/lib/core/deliver.mjs +24 -1
- package/lib/core/intake/activities.mjs +54 -0
- package/lib/core/intake/index.mjs +2 -0
- package/lib/core/intake/notes.mjs +7 -0
- package/lib/core/publisher/collections.mjs +7 -4
- package/lib/core/publisher/index.mjs +34 -26
- package/lib/core/publisher/notes.mjs +76 -11
- package/lib/core/publisher/questions.mjs +10 -7
- package/lib/core/store.mjs +9 -0
- package/lib/core/wire.mjs +57 -10
- package/lib/device/admin/static.mjs +2 -2
- package/lib/device/admin/surface.mjs +11 -2
- package/lib/gateway/front-core.mjs +33 -5
- package/lib/gateway/httpsig.mjs +7 -3
- package/package.json +1 -1
- package/run-agent.mjs +2 -0
- package/scripts/stage-site.mjs +1 -0
- package/web/app/agent.mjs +2 -0
- package/web/app/dist/sw.js +290 -72
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/site/_redirects +1 -0
- package/web/app/site/sw.js +290 -72
package/README.md
CHANGED
|
@@ -37,6 +37,11 @@ Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-i
|
|
|
37
37
|
4. Enter your pod password once. It creates the account and locks your
|
|
38
38
|
signing key. The password is not stored.
|
|
39
39
|
|
|
40
|
+
<!-- CLAUDE 2026-09-15 — the short profile address; delete these markers when done -->
|
|
41
|
+
Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
|
|
42
|
+
not.
|
|
43
|
+
<!-- /CLAUDE -->
|
|
44
|
+
|
|
40
45
|
Your account then opens in the client. To use it from another browser, go to
|
|
41
46
|
https://fedipod.net, enter your address, sign in at your pod, and unlock your
|
|
42
47
|
key with your password once on that browser.
|
package/groups.md
CHANGED
|
@@ -55,6 +55,7 @@ queue is its own list, separate from post review, and appears on the group's
|
|
|
55
55
|
admin page as **Moderation requests** when something is waiting: carry it out,
|
|
56
56
|
or turn it down.
|
|
57
57
|
A followed group's own announced deletion of a post it carried to you is honoured.
|
|
58
|
+
A group that withdraws a post it carried to you takes it off your timeline too.
|
|
58
59
|
|
|
59
60
|
## Inviting people
|
|
60
61
|
|
package/lib/client/c2s.mjs
CHANGED
|
@@ -175,7 +175,7 @@ export class C2S {
|
|
|
175
175
|
+ 'actor document names; this address is the owner reading their own' });
|
|
176
176
|
}
|
|
177
177
|
const reader = await this.auth(req, pathname);
|
|
178
|
-
if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
|
|
178
|
+
if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
|
|
179
179
|
return this.sendInbox(res, url);
|
|
180
180
|
}
|
|
181
181
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
@@ -190,7 +190,7 @@ export class C2S {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
const who = await this.auth(req, pathname);
|
|
193
|
-
if (!who.ok) return this.send(res, who.status, { error: who.error });
|
|
193
|
+
if (!who.ok) return this.send(res, who.status, { error: who.error }, who.headers || {});
|
|
194
194
|
|
|
195
195
|
// A viewer (another agent holds the lease) may not act — but the user
|
|
196
196
|
// acting HERE outranks an idle active agent elsewhere, exactly as on the
|
|
@@ -262,6 +262,16 @@ export class C2S {
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// The actors a client addressed by id, beyond the audience words: the ones
|
|
266
|
+
// in to/cc are listed and delivered to; the ones in bto/bcc are delivered
|
|
267
|
+
// to and never listed (§6).
|
|
268
|
+
addressedActors(activity, object) {
|
|
269
|
+
const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
|
|
270
|
+
.filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
|
|
271
|
+
&& a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
|
|
272
|
+
return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
|
|
273
|
+
}
|
|
274
|
+
|
|
265
275
|
async _dispatch(activity, { slug, raw, reply }) {
|
|
266
276
|
const agent = this.agent;
|
|
267
277
|
const object = typeof activity.object === 'object' && activity.object !== null
|
|
@@ -273,13 +283,14 @@ export class C2S {
|
|
|
273
283
|
const makes = object?.type || 'Note';
|
|
274
284
|
if (!object) return reply(422, { error: 'a Create carries the object it creates' });
|
|
275
285
|
const visibility = this.visibilityOf(activity, object);
|
|
286
|
+
const { also, deliverTo } = this.addressedActors(activity, object);
|
|
276
287
|
// Not a Note and not a poll: stored as sent, under this actor, and
|
|
277
288
|
// the Create around it delivered — a Web Annotation, for one.
|
|
278
289
|
if (makes !== 'Note' && makes !== 'Question') {
|
|
279
290
|
const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
280
291
|
? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
|
|
281
292
|
: null;
|
|
282
|
-
const made = await agent.publisher.publishObject(asSent || object, { visibility, slug });
|
|
293
|
+
const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo });
|
|
283
294
|
return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
|
|
284
295
|
}
|
|
285
296
|
// `source.content` is the client's plain text when it sends one; bare
|
|
@@ -302,6 +313,7 @@ export class C2S {
|
|
|
302
313
|
inReplyTo: idOf(object.inReplyTo) || undefined,
|
|
303
314
|
visibility,
|
|
304
315
|
spoilerText: object.summary || null,
|
|
316
|
+
sensitive: object.sensitive === true,
|
|
305
317
|
});
|
|
306
318
|
return reply(201,
|
|
307
319
|
{ id: wire.createActivityId(question.id), object: question.id },
|
|
@@ -320,7 +332,8 @@ export class C2S {
|
|
|
320
332
|
attachments,
|
|
321
333
|
visibility,
|
|
322
334
|
spoilerText: object.summary || null,
|
|
323
|
-
|
|
335
|
+
sensitive: object.sensitive === true,
|
|
336
|
+
slug, also, deliverTo,
|
|
324
337
|
});
|
|
325
338
|
return reply(201, { id: wire.createActivityId(note.id), object: note.id },
|
|
326
339
|
{ location: wire.createActivityId(note.id) });
|
|
@@ -344,7 +357,8 @@ export class C2S {
|
|
|
344
357
|
})).filter((a) => a.url)
|
|
345
358
|
: null;
|
|
346
359
|
await agent.publisher.updateNote(s, {
|
|
347
|
-
content: text, spoilerText: object?.summary || null,
|
|
360
|
+
content: text, spoilerText: object?.summary || null,
|
|
361
|
+
sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
|
|
348
362
|
});
|
|
349
363
|
return reply(200, { ok: true, object: s.noteId });
|
|
350
364
|
}
|
|
@@ -117,7 +117,15 @@ export class MastoApi {
|
|
|
117
117
|
res.end(body);
|
|
118
118
|
return true;
|
|
119
119
|
};
|
|
120
|
-
|
|
120
|
+
// The address the client reached, so every self-URL handed back — the
|
|
121
|
+
// pagination links above all — names it rather than the pod's host.
|
|
122
|
+
const scheme = String(this.scheme
|
|
123
|
+
|| (req.headers?.['x-forwarded-proto'] === 'https' || req.socket?.encrypted ? 'https' : 'http')).replace(/:.*$/, '');
|
|
124
|
+
let self = url;
|
|
125
|
+
try {
|
|
126
|
+
self = new URL(`${scheme}://${req.headers?.host || 'localhost'}${this.mount || ''}${pathname}${url.search || ''}`);
|
|
127
|
+
} catch { /* a Host nothing can parse: the links keep the placeholder origin */ }
|
|
128
|
+
const ctx = { req, res, pathname, url: self, send };
|
|
121
129
|
|
|
122
130
|
if (await oauth.handle(this, ctx)) return true;
|
|
123
131
|
if (!pathname.startsWith('/api/')) return false;
|
|
@@ -102,7 +102,9 @@ export function page(api, items, url, { limit = 20, max = 40, idOf = (s) => api.
|
|
|
102
102
|
}
|
|
103
103
|
const pageItems = items.slice(0, n);
|
|
104
104
|
if (!pageItems.length) return { items: pageItems, headers: {} };
|
|
105
|
-
|
|
105
|
+
// `url` is the request as the client addressed it (index.mjs handle), so
|
|
106
|
+
// the links name that origin and mount, not the pod's host.
|
|
107
|
+
const base = url.origin + url.pathname;
|
|
106
108
|
const q = (extra) => {
|
|
107
109
|
const u = new URL(base);
|
|
108
110
|
for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
|
|
@@ -222,7 +224,7 @@ export function status(api, s, { all } = {}) {
|
|
|
222
224
|
created_at: s.published || new Date().toISOString(),
|
|
223
225
|
in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
|
|
224
226
|
in_reply_to_account_id: null,
|
|
225
|
-
sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
|
|
227
|
+
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
|
|
226
228
|
visibility: s.visibility || 'public', language: null,
|
|
227
229
|
edited_at: s.editedAt || null,
|
|
228
230
|
uri: s.noteId, url: s.link || s.noteId,
|
|
@@ -251,7 +253,7 @@ export function status(api, s, { all } = {}) {
|
|
|
251
253
|
acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
|
|
252
254
|
};
|
|
253
255
|
}),
|
|
254
|
-
tags: [],
|
|
256
|
+
tags: (s.tags || []).map(t => ({ name: t.name, url: t.url })),
|
|
255
257
|
emojis: (s.emojis || []).map(e => ({
|
|
256
258
|
shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
|
|
257
259
|
})),
|
|
@@ -37,6 +37,7 @@ export async function handle(api, ctx) {
|
|
|
37
37
|
if (ready !== true) return send(422, { error: ready });
|
|
38
38
|
}
|
|
39
39
|
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
40
|
+
const sensitive = body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1';
|
|
40
41
|
const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
|
|
41
42
|
const media = api.store.getMedia();
|
|
42
43
|
const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
|
|
@@ -67,7 +68,7 @@ export async function handle(api, ctx) {
|
|
|
67
68
|
const q = await api.agent.publisher.publishQuestion(body.status, {
|
|
68
69
|
options: asking.options, multiple: asking.multiple,
|
|
69
70
|
expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
|
|
70
|
-
inReplyTo, visibility, spoilerText,
|
|
71
|
+
inReplyTo, visibility, spoilerText, sensitive,
|
|
71
72
|
});
|
|
72
73
|
return send(200, api.status(api.store.getStatuses().find(x => x.noteId === q.id)));
|
|
73
74
|
} catch (e) {
|
|
@@ -98,7 +99,7 @@ export async function handle(api, ctx) {
|
|
|
98
99
|
let note;
|
|
99
100
|
try {
|
|
100
101
|
note = await api.agent.publisher.publishNote(body.status,
|
|
101
|
-
{ inReplyTo, attachments, visibility, spoilerText });
|
|
102
|
+
{ inReplyTo, attachments, visibility, spoilerText, sensitive });
|
|
102
103
|
} catch (e) {
|
|
103
104
|
// A direct message nobody could be found for: the sender's mistake to
|
|
104
105
|
// fix, said in the client, not a fault of the server.
|
|
@@ -126,7 +127,7 @@ export async function handle(api, ctx) {
|
|
|
126
127
|
const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
127
128
|
if (!s) return send(404, { error: 'Record not found' });
|
|
128
129
|
return send(200, [{
|
|
129
|
-
content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
|
|
130
|
+
content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!(s.spoiler || s.sensitive),
|
|
130
131
|
created_at: s.editedAt || s.published, account: api.account(s.actor),
|
|
131
132
|
media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
|
|
132
133
|
emojis: [], poll: null,
|
|
@@ -152,8 +153,10 @@ export async function handle(api, ctx) {
|
|
|
152
153
|
? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
|
|
153
154
|
: null; // null: keep what the post has
|
|
154
155
|
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
156
|
+
const sensitive = body.sensitive === undefined ? null
|
|
157
|
+
: (body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1');
|
|
155
158
|
const patched = await api.agent.publisher.updateNote(s,
|
|
156
|
-
{ content: body.status, spoilerText, attachments });
|
|
159
|
+
{ content: body.status, spoilerText, sensitive, attachments });
|
|
157
160
|
return send(200, api.status(patched || s));
|
|
158
161
|
}
|
|
159
162
|
if (mStatus && req.method === 'DELETE') {
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// oidc-auth.mjs — who may drive the C2S surface. Two proofs are accepted:
|
|
2
2
|
// the Mastodon facade's bearer (minted on this machine, so its holder is the
|
|
3
|
-
// operator by construction), or a Solid-OIDC token
|
|
4
|
-
//
|
|
3
|
+
// operator by construction), or a Solid-OIDC token with its DPoP proof,
|
|
4
|
+
// which Solid-OIDC requires, verified by the same library the wider
|
|
5
5
|
// Solid world uses. The token alone is not enough: the WebID it names must
|
|
6
6
|
// be THIS identity's owner, an authorization step the token does not carry.
|
|
7
7
|
//
|
|
@@ -25,6 +25,13 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
25
25
|
if (!req.headers.authorization) {
|
|
26
26
|
return { ok: false, status: 401, error: 'authentication required: a Solid-OIDC token (DPoP) or this agent\'s own bearer' };
|
|
27
27
|
}
|
|
28
|
+
// Solid-OIDC binds the token to a key and has the client prove it holds
|
|
29
|
+
// that key on every request. A token shown without its proof is a token
|
|
30
|
+
// anyone who saw it could show.
|
|
31
|
+
if (!req.headers.dpop) {
|
|
32
|
+
return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
|
|
33
|
+
headers: { 'www-authenticate': 'DPoP' } };
|
|
34
|
+
}
|
|
28
35
|
let webid;
|
|
29
36
|
try {
|
|
30
37
|
const v = await loadVerifier();
|
|
@@ -38,7 +45,7 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
38
45
|
}://${req.headers.host}${mount}${pathname}`;
|
|
39
46
|
({ webid } = await v(
|
|
40
47
|
req.headers.authorization,
|
|
41
|
-
|
|
48
|
+
{ header: req.headers.dpop, method: req.method, url: htu },
|
|
42
49
|
));
|
|
43
50
|
} catch (e) {
|
|
44
51
|
log(`c2s auth: token rejected — ${e.message}`);
|
package/lib/core/as2.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// common shape: one that skipped it would not be read as JSON-LD, and would
|
|
20
20
|
// produce no graph, so nothing could be validated against it.
|
|
21
21
|
//
|
|
22
|
-
// CONTEXTS ARE NEVER FETCHED. The loader serves the
|
|
22
|
+
// CONTEXTS ARE NEVER FETCHED. The loader serves the fifteen the fediverse
|
|
23
23
|
// actually uses and refuses every other URL. That refusal is the point: this
|
|
24
24
|
// code runs on documents a stranger wrote, in the same path the SSRF guards
|
|
25
25
|
// protect, and expanding one would otherwise mean dereferencing a URL of their
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { webcrypto } from 'node:crypto';
|
|
|
8
8
|
import { attachProof } from './proof.mjs';
|
|
9
9
|
import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from '../shared/safefetch.mjs';
|
|
10
10
|
import { USER_AGENT } from '../shared/ua.mjs';
|
|
11
|
+
import { dropFollower } from './store.mjs';
|
|
11
12
|
|
|
12
13
|
const MAX_REDIRECTS = 3;
|
|
13
14
|
|
|
@@ -52,14 +53,20 @@ const MAX_QUEUE = 2000; // beyond it, overflow dead-lette
|
|
|
52
53
|
// the server, or no answer at all, is grounds to leave it alone.
|
|
53
54
|
const hostOf = (inbox) => { try { return new URL(inbox).host; } catch { return inbox; } };
|
|
54
55
|
const aboutTheHost = (e) => !e.status || e.status >= 500 || e.status === 429;
|
|
56
|
+
// A 410 says the inbox is gone for good and a 404 that nothing answers there:
|
|
57
|
+
// neither earns a second POST. Gone also means whoever read there has left.
|
|
58
|
+
const unsalvageable = (e) => e.status === 404 || e.status === 410;
|
|
55
59
|
|
|
56
60
|
export class Deliverer {
|
|
57
61
|
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
58
62
|
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
59
63
|
// it live when a viewer is promoted to active.
|
|
60
64
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
61
|
-
log = console.log, passive = false }) {
|
|
65
|
+
log = console.log, passive = false, onGone = null }) {
|
|
62
66
|
this.store = store;
|
|
67
|
+
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
68
|
+
// its followers collection.
|
|
69
|
+
this.onGone = onGone;
|
|
63
70
|
this.keyId = keyId;
|
|
64
71
|
this.rsaPrivate = rsaPrivate;
|
|
65
72
|
this.actorId = actorId;
|
|
@@ -182,6 +189,7 @@ export class Deliverer {
|
|
|
182
189
|
await this.deliverNow(inbox, signed);
|
|
183
190
|
this.log(`delivered ${signed.type} → ${inbox}`);
|
|
184
191
|
} catch (e) {
|
|
192
|
+
if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
|
|
185
193
|
this.log(`delivery failed (${e.message}) — queued`);
|
|
186
194
|
const wait = e.retryAfterMs || 60_000;
|
|
187
195
|
if (aboutTheHost(e)) {
|
|
@@ -192,6 +200,20 @@ export class Deliverer {
|
|
|
192
200
|
}
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
// Not retried, and on a 410 the followers who received there are dropped,
|
|
204
|
+
// so the next fan-out stops asking; the followers collection is republished
|
|
205
|
+
// through onGone. A 404 drops only this delivery.
|
|
206
|
+
async _unsalvageable(inbox, e) {
|
|
207
|
+
if (e.status !== 410) { this.log(`delivery dropped (${e.message}) — nothing answers there`); return; }
|
|
208
|
+
const contacts = this.store.getContacts?.();
|
|
209
|
+
const left = (contacts?.followers || []).filter(f => f.inbox === inbox || f.sharedInbox === inbox);
|
|
210
|
+
this.log(`${inbox} is gone (410) — delivery dropped${left.length ? `, ${left.length} follower(s) there forgotten` : ''}`);
|
|
211
|
+
if (!left.length) return;
|
|
212
|
+
for (const f of left) dropFollower(contacts, f.actor, 'inbox-gone');
|
|
213
|
+
this.store.setContacts(contacts);
|
|
214
|
+
try { await this.onGone?.(); } catch (err) { this.log(`followers republish after 410: ${err.message}`); }
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
// The queue has a ceiling: a fan-out into a sea of down servers must not grow
|
|
196
218
|
// queue.json without bound. Overflow keeps the evidence as a dead letter.
|
|
197
219
|
_enqueue(item) {
|
|
@@ -292,6 +314,7 @@ export class Deliverer {
|
|
|
292
314
|
changed = true;
|
|
293
315
|
this.log(`retry ok: ${item.activity.type} → ${item.inbox}`);
|
|
294
316
|
} catch (e) {
|
|
317
|
+
if (unsalvageable(e)) { changed = true; await this._unsalvageable(item.inbox, e); continue; }
|
|
295
318
|
item.attempts += 1;
|
|
296
319
|
changed = true;
|
|
297
320
|
const wait = e.retryAfterMs || ladderFor(item);
|
|
@@ -103,6 +103,8 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export async function onUndo(intake, activity, actor, { trusted = false } = {}) {
|
|
106
|
+
const inner = activity.object && typeof activity.object === 'object' ? activity.object : null;
|
|
107
|
+
if (inner?.type === 'Like' || inner?.type === 'Announce') return onUndoReaction(intake, inner, actor, { trusted });
|
|
106
108
|
// AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
|
|
107
109
|
// id we stored. Reading `.type` off a string gives undefined, so the whole
|
|
108
110
|
// Undo was dropped — silently, since handle() reads that as handled, so no
|
|
@@ -274,6 +276,58 @@ export async function onAnnounce(intake, activity, actor, objectId) {
|
|
|
274
276
|
return intake.ingestNote(objectId, actor, { via: actor });
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
// Undo{Like} and Undo{Announce}: the sender unsaying a favourite or a boost.
|
|
280
|
+
// The Like it unsays was recorded on the sender's word alone, so its undoing
|
|
281
|
+
// is taken the same way: the notification it left goes. A boost unsaid by
|
|
282
|
+
// someone we follow takes the post they carried off the timeline — the carry
|
|
283
|
+
// was accepted on that follow, and the retraction is honoured on it.
|
|
284
|
+
export async function onUndoReaction(intake, inner, actor, { trusted = false } = {}) {
|
|
285
|
+
const innerActor = typeof inner.actor === 'string' ? inner.actor : inner.actor?.id;
|
|
286
|
+
if (innerActor && innerActor !== actor) return; // one cannot unsay another's
|
|
287
|
+
const wrapped = inner.object;
|
|
288
|
+
const target = (wrapped && typeof wrapped === 'object'
|
|
289
|
+
&& (wrapped.type === 'Create' || wrapped.type === 'Update')) ? wrapped.object : wrapped;
|
|
290
|
+
const objectId = typeof target === 'string' ? target : target?.id;
|
|
291
|
+
if (!objectId) return;
|
|
292
|
+
if (objectId.startsWith(intake.urls.notes)) {
|
|
293
|
+
const type = inner.type === 'Like' ? 'favourite' : 'reblog';
|
|
294
|
+
const gone = intake.store.removeNotifications(n => n.type === type && n.actor === actor && n.noteId === objectId);
|
|
295
|
+
if (gone) intake.log(`${inner.type} withdrawn by ${actor} on ${objectId}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (inner.type !== 'Announce') return;
|
|
299
|
+
const followed = trusted || intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
300
|
+
if (!followed) return;
|
|
301
|
+
const s = intake.store.getStatuses().find(x => x.noteId === objectId);
|
|
302
|
+
if (!s || s.kind === 'post' || s.via !== actor) return;
|
|
303
|
+
await intake.forget(s);
|
|
304
|
+
intake.log(`boost withdrawn by ${actor}: ${objectId} left the timeline`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Block: the sender wants nothing more to do with this account. Mastodon
|
|
308
|
+
// drops the follow both ways on receipt, and so does this — on a delivery
|
|
309
|
+
// the door verified. A Block carries nothing to check and names nothing we
|
|
310
|
+
// hold, so one taken from the public-append inbox unverified would let
|
|
311
|
+
// anybody evict any follower, the hole the Undo handler closes with the
|
|
312
|
+
// follow id. Unverified: logged, nothing else.
|
|
313
|
+
export async function onBlock(intake, activity, actor, { trusted = false } = {}) {
|
|
314
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
315
|
+
if (objectId !== intake.urls.actor) return;
|
|
316
|
+
if (!trusted) { intake.log(`Block from ${actor} arrived unverified — ignored`); return; }
|
|
317
|
+
const contacts = intake.store.getContacts();
|
|
318
|
+
const wasFollower = contacts.followers.some(f => f.actor === actor);
|
|
319
|
+
const wasFollowing = contacts.following.some(f => f.actor === actor);
|
|
320
|
+
if (wasFollower) dropFollower(contacts, actor, 'blocked-us');
|
|
321
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
322
|
+
intake.store.setContacts(contacts);
|
|
323
|
+
const reqs = intake.store.getRequests();
|
|
324
|
+
const left = reqs.filter(r => r.actor !== actor);
|
|
325
|
+
if (left.length !== reqs.length) intake.store.setRequests(left);
|
|
326
|
+
await intake.republish({ followers: wasFollower, following: wasFollowing, pending: true });
|
|
327
|
+
const what = [wasFollower && 'their follow of us', wasFollowing && 'our follow of them'].filter(Boolean).join(' and ');
|
|
328
|
+
intake.log(`blocked by ${actor}: ${what || 'no relationship'} dropped`);
|
|
329
|
+
}
|
|
330
|
+
|
|
277
331
|
// Mastodon sends these constantly; ignoring them left deleted posts standing
|
|
278
332
|
// for good. Two guards, because a forged Delete would otherwise erase anyone's
|
|
279
333
|
// content: it must come from the object's own origin, and the object must
|
|
@@ -584,6 +584,7 @@ export class Intake {
|
|
|
584
584
|
case 'Reject': return this.onReject(activity, actor, { trusted });
|
|
585
585
|
case 'Move': return this.onMove(activity, actor);
|
|
586
586
|
case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
|
|
587
|
+
case 'Block': return this.onBlock(activity, actor, { trusted });
|
|
587
588
|
default: this.log(`ignored ${activity.type} from ${actor}`);
|
|
588
589
|
}
|
|
589
590
|
}
|
|
@@ -636,6 +637,7 @@ export class Intake {
|
|
|
636
637
|
onAddRemove(...a) { return activities.onAddRemove(this, ...a); }
|
|
637
638
|
onFollow(...a) { return activities.onFollow(this, ...a); }
|
|
638
639
|
onUndo(...a) { return activities.onUndo(this, ...a); }
|
|
640
|
+
onBlock(...a) { return activities.onBlock(this, ...a); }
|
|
639
641
|
onCreate(...a) { return activities.onCreate(this, ...a); }
|
|
640
642
|
onAnnouncedDelete(...a) { return activities.onAnnouncedDelete(this, ...a); }
|
|
641
643
|
onAnnounce(...a) { return activities.onAnnounce(this, ...a); }
|
|
@@ -190,6 +190,11 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
|
|
|
190
190
|
.slice(0, MAX_MENTIONS)
|
|
191
191
|
.map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
|
|
192
192
|
.filter(m => m.href);
|
|
193
|
+
const hashtags = [].concat(note.tag || [])
|
|
194
|
+
.filter(t => t?.type === 'Hashtag' && t.name)
|
|
195
|
+
.slice(0, MAX_MENTIONS)
|
|
196
|
+
.map(t => ({ name: String(t.name).replace(/^#/, '').slice(0, 128), url: httpOnly(String(t.href || '').slice(0, MAX_URL_CHARS)) }))
|
|
197
|
+
.filter(t => t.name);
|
|
193
198
|
const emojis = emojisOf(note);
|
|
194
199
|
const poll = pollOf(note);
|
|
195
200
|
// Explicitly addressed, but to nobody public and to no followers
|
|
@@ -209,6 +214,8 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
|
|
|
209
214
|
...(nonPublic ? { nonPublic: true } : {}),
|
|
210
215
|
// The author's content warning, shown as one: plain text only.
|
|
211
216
|
...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
|
|
217
|
+
...(note.sensitive === true ? { sensitive: true } : {}),
|
|
218
|
+
...(hashtags.length ? { tags: hashtags } : {}),
|
|
212
219
|
...(poll ? { poll } : {}),
|
|
213
220
|
...(emojis.length ? { emojis } : {}),
|
|
214
221
|
...(mentions.length ? { mentions } : {}),
|
|
@@ -86,7 +86,10 @@ export async function publishOutbox(publisher, outbox, { acls = false, force = f
|
|
|
86
86
|
// the flat collection this used to write, so an actor published before paging
|
|
87
87
|
// is still readable — which matters because rebuild reads this to recover
|
|
88
88
|
// posts a lost machine no longer has.
|
|
89
|
-
export function readPublishedOutbox(publisher) {
|
|
89
|
+
export async function readPublishedOutbox(publisher) {
|
|
90
|
+
const items = await podOutbox.readPublished(publisher.remote, publisher.urls);
|
|
91
|
+
return Array.isArray(items) ? items.map(wire.outboxLocalItem) : items;
|
|
92
|
+
}
|
|
90
93
|
|
|
91
94
|
// The followers collection, paged like the outbox: a head that carries only
|
|
92
95
|
// the count and the page bounds, and page documents holding the actor IRIs —
|
|
@@ -191,9 +194,9 @@ export async function publishBlocked(publisher) {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
// The outbox is the public record of everything this actor has said, boosts
|
|
194
|
-
// included. A
|
|
195
|
-
// has only a fragment id, so the activity
|
|
196
|
-
//
|
|
197
|
+
// included. A post is recorded by its note id and published as its Create
|
|
198
|
+
// (wire.outboxWireItem); an Announce has only a fragment id, so the activity
|
|
199
|
+
// itself goes in.
|
|
197
200
|
export async function recordOutbox(publisher, item) {
|
|
198
201
|
const outbox = publisher.store.read('outbox.json', []);
|
|
199
202
|
outbox.unshift(item);
|
|
@@ -34,7 +34,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
34
34
|
|
|
35
35
|
export class Publisher {
|
|
36
36
|
constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
37
|
-
probeFetch = null, resolveMention = null, clientOrigin = null,
|
|
37
|
+
probeFetch = null, resolveMention = null, resolveActor = null, clientOrigin = null,
|
|
38
38
|
}) {
|
|
39
39
|
this.config = config;
|
|
40
40
|
this.remote = remote;
|
|
@@ -57,6 +57,9 @@ export class Publisher {
|
|
|
57
57
|
// socket opened to that pod. Tests inject their own.
|
|
58
58
|
this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
|
|
59
59
|
this.resolveMention = resolveMention;
|
|
60
|
+
// An actor or object by its address, verified at its origin — how a reply
|
|
61
|
+
// finds the inbox of the person it answers.
|
|
62
|
+
this.resolveActor = resolveActor;
|
|
60
63
|
// Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
|
|
61
64
|
this.pollTimers = new Map();
|
|
62
65
|
this.log = log;
|
|
@@ -85,22 +88,14 @@ export class Publisher {
|
|
|
85
88
|
return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
// Every actor document this identity publishes, from one place: the
|
|
92
|
+
// profile publish and a Move both write it, and a Move that rebuilt it
|
|
93
|
+
// by hand dropped the gateway inbox, the outbox door and the collections.
|
|
94
|
+
actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null } = {}) {
|
|
89
95
|
const { urls } = this;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
|
|
93
|
-
// where "owner-only" is real — the pod must provably enforce the private
|
|
94
|
-
// container's ACL, the same bar private posts clear.
|
|
95
|
-
const priv = await this.privateReady() === true;
|
|
96
|
-
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
97
|
-
// An advertised inbox gateway (config.gateway.mode past 'off') becomes the
|
|
98
|
-
// actor's inbox; deliveries reach the pod inbox through it. Absent → today.
|
|
99
|
-
const gw = this.config.gateway;
|
|
100
|
-
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
101
|
-
const actorDoc = wire.actorDoc({
|
|
96
|
+
return wire.actorDoc({
|
|
102
97
|
urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
103
|
-
movedTo
|
|
98
|
+
movedTo, kind: this.config.kind,
|
|
104
99
|
approveJoins: wire.followsNeedApproval(this.config),
|
|
105
100
|
assertionKey: this.assertionKey,
|
|
106
101
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
@@ -111,7 +106,7 @@ export class Publisher {
|
|
|
111
106
|
pendingFollowers: priv ? urls.pendingFollowers : null,
|
|
112
107
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
113
108
|
blocked: priv ? urls.blocked : null,
|
|
114
|
-
inbox
|
|
109
|
+
inbox,
|
|
115
110
|
// The agent's own outbox endpoint, where it is reachable: a client
|
|
116
111
|
// following the actor must arrive somewhere that will take a write.
|
|
117
112
|
// Otherwise the Gateway's outbox door, when one is attached.
|
|
@@ -121,6 +116,22 @@ export class Publisher {
|
|
|
121
116
|
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
122
117
|
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
123
118
|
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async publishProfile({ force = false } = {}) {
|
|
122
|
+
const { urls } = this;
|
|
123
|
+
const host = new URL(urls.base).host;
|
|
124
|
+
|
|
125
|
+
// The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
|
|
126
|
+
// where "owner-only" is real — the pod must provably enforce the private
|
|
127
|
+
// container's ACL, the same bar private posts clear.
|
|
128
|
+
const priv = await this.privateReady() === true;
|
|
129
|
+
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
130
|
+
// An advertised inbox gateway (config.gateway.mode past 'off') becomes the
|
|
131
|
+
// actor's inbox; deliveries reach the pod inbox through it. Absent → today.
|
|
132
|
+
const gw = this.config.gateway;
|
|
133
|
+
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
134
|
+
const actorDoc = this.actorDocFor({ priv, moderators, inbox: gwActive ? gw.url : null });
|
|
124
135
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
125
136
|
actor: actorDoc, handle: this.config.handle, host,
|
|
126
137
|
quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
|
|
@@ -132,7 +143,7 @@ export class Publisher {
|
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
await discovery.writeWebfinger(this.remote, urls,
|
|
135
|
-
wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
|
|
146
|
+
wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
|
|
136
147
|
await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
|
|
137
148
|
|
|
138
149
|
const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
|
|
@@ -358,15 +369,12 @@ export class Publisher {
|
|
|
358
369
|
await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
|
|
359
370
|
this.config.movedTo = target; // so the republish below carries it
|
|
360
371
|
this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
image: this.config.image || null, fields: this.config.fields || [],
|
|
368
|
-
webId: this.remote.webId || null,
|
|
369
|
-
aliases: this.config.aliases || [],
|
|
372
|
+
const gw = this.config.gateway;
|
|
373
|
+
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
374
|
+
const priv = await this.privateReady() === true;
|
|
375
|
+
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
376
|
+
await podActor.writeMoved(this.remote, urls, this.actorDocFor({
|
|
377
|
+
priv, moderators, inbox: gwActive ? gw.url : null, movedTo: target,
|
|
370
378
|
}));
|
|
371
379
|
await this.store.flush();
|
|
372
380
|
this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
|