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
|
@@ -83,6 +83,45 @@ export async function mentionsFor(publisher, content, inReplyTo) {
|
|
|
83
83
|
return mentions;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Who a reply is answering. Their server gets the post whether or not the
|
|
87
|
+
// text names them — that is how the thread stays whole where they are — but
|
|
88
|
+
// no Mention tag is added, so a handle the author trimmed out still means no
|
|
89
|
+
// notification. Nothing for a direct post: it goes to whom it names. The
|
|
90
|
+
// parent is read from the timeline row; a parent the agent never held (a
|
|
91
|
+
// client-to-server reply to any address) is fetched to find its author.
|
|
92
|
+
export async function replyTarget(publisher, inReplyTo, mentions = [], visibility = 'public') {
|
|
93
|
+
if (!inReplyTo || visibility === 'direct' || !publisher.resolveActor) return null;
|
|
94
|
+
const parent = publisher.store.getStatuses().find(s => s.noteId === inReplyTo);
|
|
95
|
+
let actor = parent?.actor || null;
|
|
96
|
+
if (!parent) {
|
|
97
|
+
const doc = await publisher.resolveActor(inReplyTo).catch(() => null);
|
|
98
|
+
const by = doc?.attributedTo;
|
|
99
|
+
actor = typeof by === 'string' ? by : (Array.isArray(by) ? by[0] : by)?.id || null;
|
|
100
|
+
if (typeof actor === 'object') actor = actor?.id || null;
|
|
101
|
+
}
|
|
102
|
+
if (!actor || actor === publisher.urls.actor) return null;
|
|
103
|
+
if (mentions.some(m => m.actor === actor)) return null;
|
|
104
|
+
if (publisher.store.isBlocked?.(actor)) return null;
|
|
105
|
+
const doc = await publisher.resolveActor(actor).catch(() => null);
|
|
106
|
+
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox || null;
|
|
107
|
+
if (!inbox) publisher.log(`reply: no inbox found for ${actor} — they get it only through followers`);
|
|
108
|
+
return { actor, inbox };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Inboxes for actors a client addressed by id — the people in a post's
|
|
112
|
+
// to/cc and the ones in bto/bcc, who are delivered to and never listed.
|
|
113
|
+
export async function inboxesFor(publisher, actorIds = []) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const id of [...new Set(actorIds.filter(a => typeof a === 'string' && a && a !== publisher.urls.actor))]) {
|
|
116
|
+
if (!publisher.resolveActor) break;
|
|
117
|
+
const doc = await publisher.resolveActor(id).catch(() => null);
|
|
118
|
+
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox;
|
|
119
|
+
if (inbox) out.push(inbox);
|
|
120
|
+
else publisher.log(`addressed actor ${id} has no inbox the agent can find — not delivered to`);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
// A direct post goes only to the people it names, so one that names nobody
|
|
87
126
|
// the agent can find would be kept in the private container and delivered
|
|
88
127
|
// to no one, with nothing said. Refused instead, before anything is written,
|
|
@@ -120,7 +159,7 @@ export function rowContent(obj) {
|
|
|
120
159
|
return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
|
|
121
160
|
}
|
|
122
161
|
|
|
123
|
-
export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, slug: wanted = null } = {}) {
|
|
162
|
+
export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null, also = [], deliverTo = [] } = {}) {
|
|
124
163
|
const { urls } = publisher;
|
|
125
164
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
126
165
|
if (priv) {
|
|
@@ -131,8 +170,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
131
170
|
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
132
171
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
133
172
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
173
|
+
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
134
174
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
135
|
-
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes
|
|
175
|
+
visibility, summary: spoilerText, sensitive, container: priv ? urls.privateNotes : urls.notes,
|
|
176
|
+
also: [...also, ...(reply ? [reply.actor] : [])] });
|
|
177
|
+
const addressedInboxes = await inboxesFor(publisher, [...also, ...deliverTo]);
|
|
136
178
|
|
|
137
179
|
await podNotes.write(publisher.remote, note.id, note);
|
|
138
180
|
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
@@ -145,8 +187,9 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
145
187
|
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
146
188
|
kind: 'post', slug, text: content, visibility,
|
|
147
189
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
190
|
+
...(note.sensitive ? { sensitive: true } : {}),
|
|
148
191
|
...(attachments?.length ? { attachments } : {}),
|
|
149
|
-
...(note
|
|
192
|
+
...rowTags(note),
|
|
150
193
|
});
|
|
151
194
|
// The row is what the author's own timeline reads. Land it before answering
|
|
152
195
|
// — the same PUT the debounce would send in 300 ms — so a worker killed or a
|
|
@@ -164,6 +207,8 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
164
207
|
const inboxes = [...new Set([
|
|
165
208
|
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
166
209
|
...mentions.map(m => m.inbox),
|
|
210
|
+
reply?.inbox,
|
|
211
|
+
...addressedInboxes,
|
|
167
212
|
].filter(Boolean))];
|
|
168
213
|
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
169
214
|
publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
@@ -199,7 +244,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
199
244
|
// An object already living on this pod (an id under it) is not copied: the
|
|
200
245
|
// Create names it where it is. Anything else is written into the notes
|
|
201
246
|
// container under the client's slug when it is free, else a minted one.
|
|
202
|
-
export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null } = {}) {
|
|
247
|
+
export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null, also = [], deliverTo = [] } = {}) {
|
|
203
248
|
const { urls } = publisher;
|
|
204
249
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
205
250
|
if (priv) {
|
|
@@ -212,11 +257,15 @@ export async function publishObject(publisher, object, { visibility = 'public',
|
|
|
212
257
|
const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
|
|
213
258
|
? object.id : null;
|
|
214
259
|
const name = await slugFor(publisher, container, wanted, published);
|
|
215
|
-
const addressed = wire.addressing(urls, visibility);
|
|
260
|
+
const addressed = wire.addressing(urls, visibility, visibility === 'direct' ? also : []);
|
|
261
|
+
if (visibility !== 'direct') addressed.cc = [...new Set([...addressed.cc, ...also])];
|
|
216
262
|
let doc = null;
|
|
217
263
|
const id = ownId || container + name;
|
|
218
264
|
if (!ownId) {
|
|
219
|
-
|
|
265
|
+
// bto and bcc are for delivery alone: they never appear in what is
|
|
266
|
+
// stored, served or sent (ActivityPub §6).
|
|
267
|
+
const { bto, bcc, ...sent } = object; // eslint-disable-line no-unused-vars
|
|
268
|
+
doc = { ...sent, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
|
|
220
269
|
if (!doc['@context']) doc['@context'] = wire.AS_CTX;
|
|
221
270
|
if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
|
|
222
271
|
await podNotes.write(publisher.remote, id, doc);
|
|
@@ -239,14 +288,25 @@ export async function publishObject(publisher, object, { visibility = 'public',
|
|
|
239
288
|
};
|
|
240
289
|
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
241
290
|
const contacts = publisher.store.getContacts();
|
|
242
|
-
const inboxes =
|
|
243
|
-
|
|
291
|
+
const inboxes = [...new Set([
|
|
292
|
+
...(visibility === 'direct' ? [] : contacts.followers.map((f) => f.sharedInbox || f.inbox)),
|
|
293
|
+
...(await inboxesFor(publisher, [...also, ...deliverTo])),
|
|
294
|
+
].filter(Boolean))];
|
|
244
295
|
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
245
296
|
publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
|
|
246
297
|
return { id, createId, copied: !ownId };
|
|
247
298
|
}
|
|
248
299
|
|
|
249
|
-
|
|
300
|
+
// What the author's timeline row keeps of a note's tags: the people it
|
|
301
|
+
// mentions and the hashtags it carries, each as the client-facing shape.
|
|
302
|
+
export function rowTags(note) {
|
|
303
|
+
const tags = [].concat(note.tag || []);
|
|
304
|
+
const mentions = tags.filter(t => t.type === 'Mention').map(t => ({ href: t.href, name: t.name }));
|
|
305
|
+
const hashtags = tags.filter(t => t.type === 'Hashtag').map(t => ({ name: String(t.name).replace(/^#/, ''), url: t.href }));
|
|
306
|
+
return { ...(mentions.length ? { mentions } : {}), ...(hashtags.length ? { tags: hashtags } : {}) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null } = {}) {
|
|
250
310
|
const { urls } = publisher;
|
|
251
311
|
const updated = new Date().toISOString();
|
|
252
312
|
const inText = new Set(wire.mentionsIn(content));
|
|
@@ -262,24 +322,29 @@ export async function updateNote(publisher, s, { content, spoilerText = null, at
|
|
|
262
322
|
// post may carry no slug, but the note id already contains it.
|
|
263
323
|
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
264
324
|
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
325
|
+
const reply = await replyTarget(publisher, s.inReplyTo, mentions, s.visibility || 'public');
|
|
265
326
|
const note = wire.noteDoc({
|
|
266
327
|
urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
|
|
267
328
|
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
268
|
-
summary: spoilerText, updated, container,
|
|
329
|
+
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
330
|
+
also: reply ? [reply.actor] : [],
|
|
269
331
|
});
|
|
270
332
|
await podNotes.write(publisher.remote, note.id, note);
|
|
271
333
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
272
334
|
const patched = publisher.store.updateStatus(s.noteId, {
|
|
273
335
|
content: note.content, text: content, editedAt: updated,
|
|
274
336
|
spoiler: spoilerText || undefined,
|
|
337
|
+
sensitive: note.sensitive || undefined,
|
|
275
338
|
attachments: atts.length ? atts : undefined,
|
|
276
|
-
mentions: note.
|
|
339
|
+
mentions: rowTags(note).mentions,
|
|
340
|
+
tags: rowTags(note).tags,
|
|
277
341
|
});
|
|
278
342
|
const update = wire.updateActivity(note, urls);
|
|
279
343
|
const contacts = publisher.store.getContacts();
|
|
280
344
|
const inboxes = [...new Set([
|
|
281
345
|
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
282
346
|
...mentions.map(m => m.inbox),
|
|
347
|
+
reply?.inbox,
|
|
283
348
|
].filter(Boolean))];
|
|
284
349
|
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
285
350
|
publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import * as wire from '../wire.mjs';
|
|
10
10
|
import * as polls from '../polls.mjs';
|
|
11
11
|
import * as podNotes from '../../pod/notes.mjs';
|
|
12
|
-
import { assertDirectAddressed } from './notes.mjs';
|
|
12
|
+
import { assertDirectAddressed, replyTarget, rowTags } from './notes.mjs';
|
|
13
13
|
|
|
14
14
|
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
15
15
|
// changes a number other servers re-read, and rewriting per vote would make a
|
|
@@ -22,7 +22,7 @@ const POLL_REWRITE_MS = 10_000;
|
|
|
22
22
|
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
23
23
|
*/
|
|
24
24
|
export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
|
|
25
|
-
inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
|
|
25
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false } = {}) {
|
|
26
26
|
const { urls } = publisher;
|
|
27
27
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
28
28
|
if (priv) {
|
|
@@ -54,6 +54,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
54
54
|
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
55
55
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
56
56
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
57
|
+
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
57
58
|
const poll = {
|
|
58
59
|
multiple: !!multiple,
|
|
59
60
|
expiresAt: expiresAt || null,
|
|
@@ -62,11 +63,11 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
62
63
|
votersCount: 0,
|
|
63
64
|
// Resolved once, here: a tally rewrite must not cost a webfinger lookup
|
|
64
65
|
// per vote for people the poll named.
|
|
65
|
-
mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
|
|
66
|
+
mentionInboxes: [...new Set([...mentions.map(m => m.inbox), reply?.inbox].filter(Boolean))],
|
|
66
67
|
};
|
|
67
68
|
const question = wire.questionDoc({
|
|
68
69
|
urls, slug, content, published, inReplyTo, attachments: [], mentions,
|
|
69
|
-
visibility, summary: spoilerText,
|
|
70
|
+
visibility, summary: spoilerText, sensitive, also: reply ? [reply.actor] : [],
|
|
70
71
|
container: priv ? urls.privateNotes : urls.notes,
|
|
71
72
|
options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
|
|
72
73
|
});
|
|
@@ -79,8 +80,10 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
79
80
|
publisher.store.addStatus({
|
|
80
81
|
noteId: question.id, actor: urls.actor, content: question.content, published,
|
|
81
82
|
kind: 'post', slug, text: content, visibility, poll, inReplyTo,
|
|
83
|
+
...(reply ? { replyActor: reply.actor } : {}),
|
|
82
84
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
83
|
-
...(question.
|
|
85
|
+
...(question.sensitive ? { sensitive: true } : {}),
|
|
86
|
+
...rowTags(question),
|
|
84
87
|
});
|
|
85
88
|
if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
|
|
86
89
|
|
|
@@ -164,8 +167,8 @@ export async function republishPoll(publisher, questionId, { closing = null } =
|
|
|
164
167
|
}));
|
|
165
168
|
const question = wire.questionDoc({
|
|
166
169
|
urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
|
|
167
|
-
attachments: [], mentions,
|
|
168
|
-
visibility: s.visibility || 'public', summary: s.spoiler || null, container,
|
|
170
|
+
attachments: [], mentions, also: s.replyActor ? [s.replyActor] : [],
|
|
171
|
+
visibility: s.visibility || 'public', summary: s.spoiler || null, sensitive: !!s.sensitive, container,
|
|
169
172
|
options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
|
|
170
173
|
closed: poll.closed, votersCount: poll.votersCount || 0,
|
|
171
174
|
});
|
package/lib/core/store.mjs
CHANGED
|
@@ -414,6 +414,15 @@ export class PodStore {
|
|
|
414
414
|
// — not from the pod, not from the RDF. Unverified entries are dropped first
|
|
415
415
|
// now, so a flood can only ever evict itself, and a stranger's genuine
|
|
416
416
|
// favourite still shows up, which is what recording them at all is for.
|
|
417
|
+
// What an Undo unsays: the favourite or boost it names, matched by a
|
|
418
|
+
// predicate on what was recorded. Returns how many went.
|
|
419
|
+
removeNotifications(pred) {
|
|
420
|
+
const all = this.getNotifications();
|
|
421
|
+
const kept = all.filter(n => !pred(n));
|
|
422
|
+
if (kept.length !== all.length) this.write('notifications.json', kept);
|
|
423
|
+
return all.length - kept.length;
|
|
424
|
+
}
|
|
425
|
+
|
|
417
426
|
addNotification(n) {
|
|
418
427
|
const all = this.getNotifications();
|
|
419
428
|
const id = crypto.createHash('sha256').update(JSON.stringify(n)).digest('hex').slice(0, 16);
|
package/lib/core/wire.mjs
CHANGED
|
@@ -52,11 +52,14 @@ export function hostMeta(base) {
|
|
|
52
52
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function jrd({ handle, host, actor, aliases = [] }) {
|
|
55
|
+
export function jrd({ handle, host, actor, aliases = [], page = null }) {
|
|
56
56
|
return {
|
|
57
57
|
subject: `acct:${handle}@${host}`,
|
|
58
58
|
...(aliases.length ? { aliases } : {}),
|
|
59
|
-
links: [
|
|
59
|
+
links: [
|
|
60
|
+
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
61
|
+
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
62
|
+
],
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -372,7 +375,7 @@ export function outboxPage(outboxId, n, items) {
|
|
|
372
375
|
id: outboxPageId(outboxId, n),
|
|
373
376
|
type: 'OrderedCollectionPage',
|
|
374
377
|
partOf: outboxId,
|
|
375
|
-
orderedItems: items,
|
|
378
|
+
orderedItems: items.map(outboxWireItem),
|
|
376
379
|
...(n > 1 ? { next: outboxPageId(outboxId, n - 1) } : {}),
|
|
377
380
|
};
|
|
378
381
|
}
|
|
@@ -394,6 +397,13 @@ export function outboxPages(outbox) {
|
|
|
394
397
|
|
|
395
398
|
export const outboxItemId = (i) => (typeof i === 'string' ? i : i?.id || null);
|
|
396
399
|
|
|
400
|
+
// The outbox lists activities. A post is recorded by its object id, which is
|
|
401
|
+
// what every local reader keys on, and published as the Create beside it — a
|
|
402
|
+
// document of its own. An Announce is recorded and published whole.
|
|
403
|
+
export const outboxWireItem = (i) => (typeof i === 'string' ? createActivityId(i) : i);
|
|
404
|
+
export const outboxLocalItem = (i) =>
|
|
405
|
+
(typeof i === 'string' && i.endsWith('-create') ? i.slice(0, -'-create'.length) : i);
|
|
406
|
+
|
|
397
407
|
// Assign entries to pages, and KEEP the assignment.
|
|
398
408
|
//
|
|
399
409
|
// Deriving the pages from position — which is what outboxPages does — makes an
|
|
@@ -589,10 +599,37 @@ export function mentionsIn(text) {
|
|
|
589
599
|
return [...new Set(String(text).match(MENTION_RE) || [])].map(m => m.slice(1));
|
|
590
600
|
}
|
|
591
601
|
|
|
602
|
+
// #word in the text: letters, digits and underscores with at least one
|
|
603
|
+
// letter, not glued to a word or a path (so a URL's fragment is not a tag).
|
|
604
|
+
export const HASHTAG_RE = /(^|[^\/\w)])#([\p{L}\p{N}_]*[\p{L}_][\p{L}\p{N}_]*)/gu;
|
|
605
|
+
|
|
606
|
+
export function hashtagsIn(text) {
|
|
607
|
+
const seen = new Map();
|
|
608
|
+
for (const m of String(text).matchAll(HASHTAG_RE)) {
|
|
609
|
+
const key = m[2].toLowerCase();
|
|
610
|
+
if (!seen.has(key)) seen.set(key, m[2]);
|
|
611
|
+
}
|
|
612
|
+
return [...seen.values()];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Where a tag's link points: this actor's own page, asked for the tag. A
|
|
616
|
+
// Hashtag's href must be a URL; the name is what servers file it under.
|
|
617
|
+
export function tagHref(urls, name) {
|
|
618
|
+
return urls.profileHtml + '?tag=' + encodeURIComponent(String(name).toLowerCase());
|
|
619
|
+
}
|
|
620
|
+
|
|
592
621
|
// Escape first, then linkify: escaping cannot introduce an @handle, and the
|
|
593
622
|
// anchors must survive it. A mention nobody could resolve stays plain text.
|
|
594
|
-
export function contentHtml(text, mentions = []) {
|
|
623
|
+
export function contentHtml(text, mentions = [], hashtags = []) {
|
|
595
624
|
let html = String(text).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
|
|
625
|
+
if (hashtags.length) {
|
|
626
|
+
const byName = new Map(hashtags.filter(h => h?.name && h?.href).map(h => [h.name.toLowerCase(), h.href]));
|
|
627
|
+
html = html.replace(HASHTAG_RE, (all, lead, name) => {
|
|
628
|
+
const href = byName.get(name.toLowerCase());
|
|
629
|
+
if (!href) return all;
|
|
630
|
+
return `${lead}<a href="${String(href).replace(/[&<>"]/g, c => (HTML_ESCAPES[c] || '"'))}" class="mention hashtag" rel="tag">#<span>${name}</span></a>`;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
596
633
|
for (const m of mentions) {
|
|
597
634
|
if (!m?.handle || !m?.actor) continue;
|
|
598
635
|
// The anchor is for PEOPLE — the mentioned actor's advertised page when it
|
|
@@ -619,26 +656,36 @@ export function addressing(urls, visibility, who = []) {
|
|
|
619
656
|
}[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
|
|
620
657
|
}
|
|
621
658
|
|
|
659
|
+
// `also`: actors addressed without being mentioned — the parent's author on
|
|
660
|
+
// a reply. In cc but not in tag, so their server receives the post and does
|
|
661
|
+
// not notify them. A direct post names its audience in the text alone.
|
|
622
662
|
export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
|
|
623
|
-
visibility = 'public', summary = null, updated = null, container = null }) {
|
|
663
|
+
visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [] }) {
|
|
624
664
|
const id = (container || urls.notes) + slug;
|
|
625
665
|
const who = mentions.map(m => m.actor);
|
|
626
|
-
const
|
|
666
|
+
const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
|
|
667
|
+
const addressed = addressing(urls, visibility, [...who, ...extra]);
|
|
668
|
+
const hashtags = hashtagsIn(content).map(name => ({ name, href: tagHref(urls, name) }));
|
|
627
669
|
const note = {
|
|
628
670
|
'@context': AS_CTX,
|
|
629
671
|
id, type: 'Note',
|
|
630
672
|
attributedTo: urls.actor,
|
|
631
|
-
content: contentHtml(content, mentions),
|
|
673
|
+
content: contentHtml(content, mentions, hashtags),
|
|
632
674
|
published,
|
|
633
675
|
to: addressed.to,
|
|
634
676
|
cc: addressed.cc,
|
|
635
677
|
replies: repliesId(id),
|
|
636
678
|
};
|
|
637
679
|
if (summary) note.summary = summary; // the content warning
|
|
680
|
+
// A content warning is what a receiver hides behind; media marked
|
|
681
|
+
// sensitive without one is hidden the same way.
|
|
682
|
+
if (summary || sensitive) note.sensitive = true;
|
|
638
683
|
if (updated) note.updated = updated; // an edit's own stamp
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
684
|
+
const tag = [
|
|
685
|
+
...mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle })),
|
|
686
|
+
...hashtags.map(h => ({ type: 'Hashtag', href: h.href, name: '#' + h.name })),
|
|
687
|
+
];
|
|
688
|
+
if (tag.length) note.tag = tag;
|
|
642
689
|
if (inReplyTo) note.inReplyTo = inReplyTo;
|
|
643
690
|
if (attachments?.length) {
|
|
644
691
|
note.attachment = attachments.map(a => ({
|
|
@@ -130,8 +130,8 @@ export function securityHeaders(auth, isHtml) {
|
|
|
130
130
|
return h;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export function sendJson(res, status, obj, auth) {
|
|
134
|
-
res.writeHead(status, { 'content-type':
|
|
133
|
+
export function sendJson(res, status, obj, auth, contentType = 'application/json') {
|
|
134
|
+
res.writeHead(status, { 'content-type': contentType, ...securityHeaders(auth, false) });
|
|
135
135
|
res.end(JSON.stringify(obj) + '\n');
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -181,6 +181,14 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
181
181
|
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
182
182
|
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
|
|
183
183
|
}
|
|
184
|
+
// The short address a person types: /@handle opens the profile page.
|
|
185
|
+
const at = /^\/@([^/]+)$/u.exec(p);
|
|
186
|
+
const ownHandle = handle || agent.store?.getConfig?.()?.handle || '';
|
|
187
|
+
if (at && ownHandle && agent.publisher?.urls?.profileHtml
|
|
188
|
+
&& decodeURIComponent(at[1]).toLowerCase() === String(ownHandle).toLowerCase()) {
|
|
189
|
+
res.writeHead(302, { location: agent.publisher.urls.profileHtml, 'cache-control': 'no-store' });
|
|
190
|
+
res.end(); return;
|
|
191
|
+
}
|
|
184
192
|
if (atDoor && gate(req, res)) return;
|
|
185
193
|
if (p === '/api/v1/streaming/health') {
|
|
186
194
|
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
|
@@ -191,10 +199,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
191
199
|
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
|
|
192
200
|
}
|
|
193
201
|
if (p === '/nodeinfo/2.0') {
|
|
194
|
-
|
|
202
|
+
// The schema's own media type, as the NodeInfo spec names it.
|
|
203
|
+
return sendJson(res, 200, nodeinfoDoc({
|
|
195
204
|
version: AGENT_VERSION,
|
|
196
205
|
localPosts: agent.store.countStatuses('post'),
|
|
197
|
-
}));
|
|
206
|
+
}), allowed, 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"');
|
|
198
207
|
}
|
|
199
208
|
// A group serves the client too, as of 2026-08-01. It was withheld on the
|
|
200
209
|
// reasoning that a group has no timeline a human reads — which was wrong
|
|
@@ -26,11 +26,17 @@ import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
|
26
26
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
27
27
|
// friends), which a serverless front must never carry — it crashed the
|
|
28
28
|
// deployed function before it answered its first request.
|
|
29
|
-
const jrd = ({ handle, host, actor, aliases = [] }) => ({
|
|
29
|
+
const jrd = ({ handle, host, actor, aliases = [], page = null }) => ({
|
|
30
30
|
subject: `acct:${handle}@${host}`,
|
|
31
31
|
...(aliases.length ? { aliases } : {}),
|
|
32
|
-
links: [
|
|
32
|
+
links: [
|
|
33
|
+
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
34
|
+
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
35
|
+
],
|
|
33
36
|
});
|
|
37
|
+
// The public profile page of any account the front answers for lives on
|
|
38
|
+
// its pod, whether the address is the front's or the pod's own.
|
|
39
|
+
const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' : null);
|
|
34
40
|
|
|
35
41
|
const AP_CT = 'application/activity+json';
|
|
36
42
|
|
|
@@ -41,10 +47,13 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
41
47
|
const authz = request.headers.get('authorization');
|
|
42
48
|
if (!authz) return null;
|
|
43
49
|
try {
|
|
44
|
-
const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
45
50
|
const dpop = request.headers.get('dpop');
|
|
51
|
+
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
52
|
+
// a token shown without the proof is one anyone who saw it could show.
|
|
53
|
+
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
54
|
+
const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
46
55
|
const url = request.url;
|
|
47
|
-
const { webid } = await v(authz,
|
|
56
|
+
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
48
57
|
return webid || null;
|
|
49
58
|
} catch (e) {
|
|
50
59
|
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
@@ -597,8 +606,27 @@ async function route(request, ctx) {
|
|
|
597
606
|
// is the alias, so a client signing in by the fronted address can find
|
|
598
607
|
// the pod (and its login) without a lookup only the host could answer.
|
|
599
608
|
const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
|
|
600
|
-
|
|
609
|
+
const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor, page: profilePageOf(rec) }),
|
|
601
610
|
'application/jrd+json');
|
|
611
|
+
// RFC 7033: a JRD is read from any origin.
|
|
612
|
+
out.headers['access-control-allow-origin'] = '*';
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The short address a person types: https://<host>/@handle opens the
|
|
617
|
+
// profile page, the way most Fediverse servers spell it. /@handle@pod-host
|
|
618
|
+
// is the same for an account whose address is its pod's, which the front
|
|
619
|
+
// holds under that full address — the form other servers use for a
|
|
620
|
+
// profile that lives elsewhere.
|
|
621
|
+
const at = /^\/@([^/@]+)(?:@([^/@]+))?$/u.exec(pathname);
|
|
622
|
+
if (at) {
|
|
623
|
+
const handle = decodeURIComponent(at[1]).toLowerCase();
|
|
624
|
+
const host = at[2] ? decodeURIComponent(at[2]).toLowerCase() : null;
|
|
625
|
+
const key = host && host !== String(ctx.host).toLowerCase() ? `${handle}@${host}` : handle;
|
|
626
|
+
const rec = await ctx.lookup(key);
|
|
627
|
+
const page = rec && profilePageOf(rec);
|
|
628
|
+
if (!page) return notFound();
|
|
629
|
+
return { status: 302, headers: { location: page, 'cache-control': 'no-store' }, body: '' };
|
|
602
630
|
}
|
|
603
631
|
|
|
604
632
|
const up = parseUserPath(pathname);
|
package/lib/gateway/httpsig.mjs
CHANGED
|
@@ -58,10 +58,14 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
58
58
|
} catch {
|
|
59
59
|
result = null; // the library itself failed — no more informative than a bad signature
|
|
60
60
|
}
|
|
61
|
+
// Which signature the sender used: RFC 9421 carries a Signature-Input
|
|
62
|
+
// header beside Signature; the cavage draft carries Signature alone. The
|
|
63
|
+
// library picks the verifier by the same header.
|
|
64
|
+
const method = request.headers.has('signature-input') ? 'rfc9421' : 'draft-cavage';
|
|
61
65
|
if (result?.verified) {
|
|
62
66
|
const key = result.key;
|
|
63
67
|
return {
|
|
64
|
-
verified: true, method
|
|
68
|
+
verified: true, method,
|
|
65
69
|
keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
|
|
66
70
|
checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
|
|
67
71
|
};
|
|
@@ -75,12 +79,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
75
79
|
}
|
|
76
80
|
if (kind === 'keyFetchError') {
|
|
77
81
|
return {
|
|
78
|
-
verified: false, method
|
|
82
|
+
verified: false, method, keyId: null, actor: null,
|
|
79
83
|
reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
return {
|
|
83
|
-
verified: false, method
|
|
87
|
+
verified: false, method, keyId: null, actor: null,
|
|
84
88
|
reason: 'bad-signature', checks: { signature: false },
|
|
85
89
|
};
|
|
86
90
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/run-agent.mjs
CHANGED
|
@@ -325,12 +325,14 @@ export class Agent {
|
|
|
325
325
|
actorId: this.urls.actor, edPrivate: keys.edPrivate,
|
|
326
326
|
proofKeyId: assertionKeyId(this.urls),
|
|
327
327
|
log: this.log, passive: this.viewer,
|
|
328
|
+
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
328
329
|
});
|
|
329
330
|
this.publisher = new Publisher({
|
|
330
331
|
config, remote: this.remote, store: this.store,
|
|
331
332
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
332
333
|
assertionKey: keys.edPublicMultibase, log: this.log,
|
|
333
334
|
resolveMention: (h) => resolveHandle(this, h),
|
|
335
|
+
resolveActor: (u) => this.intake.fetchAP(u),
|
|
334
336
|
// Inside a pod server the client surface answers on the pod's own
|
|
335
337
|
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
336
338
|
// naming it in a world-readable actor would send clients nowhere.
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -94,6 +94,7 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
94
94
|
'/api/agent /.netlify/functions/front 200',
|
|
95
95
|
'/u/* /.netlify/functions/front 200',
|
|
96
96
|
'/.well-known/* /.netlify/functions/front 200',
|
|
97
|
+
'/@* /.netlify/functions/front 200',
|
|
97
98
|
'/install /.netlify/functions/front 200',
|
|
98
99
|
'/run /.netlify/functions/front 200',
|
|
99
100
|
'/roster /.netlify/functions/front 200',
|
package/web/app/agent.mjs
CHANGED
|
@@ -223,6 +223,7 @@ export class BrowserAgent {
|
|
|
223
223
|
// The relay finds the account by the front's own key for it, which for a
|
|
224
224
|
// mail-door account is the full address, not the bare handle.
|
|
225
225
|
handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
|
|
226
|
+
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
226
227
|
});
|
|
227
228
|
|
|
228
229
|
this.publisher = new Publisher({
|
|
@@ -232,6 +233,7 @@ export class BrowserAgent {
|
|
|
232
233
|
// gives its publisher. Without it no mention from the browser ever
|
|
233
234
|
// resolved: a direct message went to nobody, a mention notified no one.
|
|
234
235
|
resolveMention: (h) => resolveHandle(this, h),
|
|
236
|
+
resolveActor: (u) => this.intake.fetchAP(u),
|
|
235
237
|
});
|
|
236
238
|
|
|
237
239
|
// The Bluesky connection, stamped to this actor. The same client the Node
|