fedipod 1.7.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.
Files changed (53) hide show
  1. package/README.md +14 -3
  2. package/gateway.md +16 -0
  3. package/groups.md +1 -0
  4. package/lib/client/c2s.mjs +112 -64
  5. package/lib/client/masto/index.mjs +9 -1
  6. package/lib/client/masto/render.mjs +5 -3
  7. package/lib/client/masto/statuses.mjs +7 -4
  8. package/lib/client/oidc-auth.mjs +10 -3
  9. package/lib/core/as2.mjs +1 -1
  10. package/lib/core/contexts/anno.json +126 -0
  11. package/lib/core/contexts/index.mjs +4 -0
  12. package/lib/core/contexts/map.json +2 -1
  13. package/lib/core/deliver.mjs +24 -1
  14. package/lib/core/intake/activities.mjs +54 -0
  15. package/lib/core/intake/index.mjs +34 -5
  16. package/lib/core/intake/notes.mjs +7 -0
  17. package/lib/core/publisher/collections.mjs +7 -4
  18. package/lib/core/publisher/index.mjs +49 -27
  19. package/lib/core/publisher/notes.mjs +154 -7
  20. package/lib/core/publisher/questions.mjs +11 -7
  21. package/lib/core/publisher/restore.mjs +27 -2
  22. package/lib/core/social.mjs +1 -0
  23. package/lib/core/store.mjs +13 -0
  24. package/lib/core/wire.mjs +71 -20
  25. package/lib/device/admin/static.mjs +2 -2
  26. package/lib/device/admin/surface.mjs +11 -2
  27. package/lib/gateway/front-core.mjs +76 -6
  28. package/lib/gateway/gateway-core.mjs +40 -0
  29. package/lib/gateway/httpsig.mjs +7 -3
  30. package/lib/pod/actor.mjs +2 -2
  31. package/lib/pod/transport.mjs +9 -3
  32. package/package.json +1 -1
  33. package/run-agent.mjs +11 -0
  34. package/scripts/refresh-contexts.mjs +5 -0
  35. package/scripts/stage-site.mjs +1 -0
  36. package/web/admin/index.html +2 -0
  37. package/web/admin/upkeep.js +9 -1
  38. package/web/app/README.md +1 -1
  39. package/web/app/agent.mjs +12 -0
  40. package/web/app/boot.mjs +49 -10
  41. package/web/app/dist/boot.js +47 -7
  42. package/web/app/dist/boot.js.map +2 -2
  43. package/web/app/dist/sw.js +1604 -722
  44. package/web/app/dist/sw.js.map +4 -4
  45. package/web/app/index.html +16 -6
  46. package/web/app/site/_redirects +1 -0
  47. package/web/app/site/admin/index.html +2 -0
  48. package/web/app/site/admin/upkeep.js +9 -1
  49. package/web/app/site/boot.js +89 -37
  50. package/web/app/site/index.html +16 -6
  51. package/web/app/site/sw.js +1605 -723
  52. package/web/front/#new-account.html# +0 -43
  53. package/web/front/new-account.html~ +0 -50
@@ -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,
@@ -99,7 +138,28 @@ export function assertDirectAddressed(content, mentions) {
99
138
  throw e;
100
139
  }
101
140
 
102
- export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
141
+ // A client may name the document it is creating (the Slug of a client-to-server
142
+ // POST). Only a plain name is taken, and only when nothing is there already;
143
+ // otherwise the agent mints one as it always has.
144
+ const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
145
+ export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
146
+ async function slugFor(publisher, container, wanted, published) {
147
+ const name = safeSlug(wanted);
148
+ if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
149
+ return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
150
+ }
151
+
152
+ // What the author's own timeline shows for an object: its content, else its
153
+ // text under one of the names other vocabularies use, else a link to it.
154
+ export function rowContent(obj) {
155
+ if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
156
+ const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
157
+ if (plain) return wire.contentHtml(plain);
158
+ const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
159
+ return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
160
+ }
161
+
162
+ export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null, also = [], deliverTo = [] } = {}) {
103
163
  const { urls } = publisher;
104
164
  const priv = visibility === 'private' || visibility === 'direct';
105
165
  if (priv) {
@@ -107,11 +167,14 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
107
167
  if (ready !== true) throw new Error(ready);
108
168
  }
109
169
  const published = new Date().toISOString();
110
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
170
+ const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
111
171
  const mentions = await publisher._mentionsFor(content, inReplyTo);
112
172
  if (visibility === 'direct') assertDirectAddressed(content, mentions);
173
+ const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
113
174
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
114
- 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]);
115
178
 
116
179
  await podNotes.write(publisher.remote, note.id, note);
117
180
  // Empty, but present: a dangling `replies` that 404s is worse than none.
@@ -124,9 +187,15 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
124
187
  noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
125
188
  kind: 'post', slug, text: content, visibility,
126
189
  ...(spoilerText ? { spoiler: spoilerText } : {}),
190
+ ...(note.sensitive ? { sensitive: true } : {}),
127
191
  ...(attachments?.length ? { attachments } : {}),
128
- ...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
192
+ ...rowTags(note),
129
193
  });
194
+ // The row is what the author's own timeline reads. Land it before answering
195
+ // — the same PUT the debounce would send in 300 ms — so a worker killed or a
196
+ // write refused after the answer cannot leave a post that stands on the pod
197
+ // and reached followers but never shows to its author.
198
+ if (await publisher.store.commit?.() === false) publisher.log(`post published but its timeline row was refused: ${note.id}`);
130
199
 
131
200
  const create = wire.createActivity(note, urls);
132
201
  // Published as its own document: a group that carries this post wraps the
@@ -138,6 +207,8 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
138
207
  const inboxes = [...new Set([
139
208
  ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
140
209
  ...mentions.map(m => m.inbox),
210
+ reply?.inbox,
211
+ ...addressedInboxes,
141
212
  ].filter(Boolean))];
142
213
  await publisher.deliverer.deliverToAll(inboxes, create);
143
214
  publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
@@ -164,7 +235,78 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
164
235
  // edit's own stamp. The pod documents are overwritten in place — the Create
165
236
  // too, so a group's Announce resolves to the edited text — and an Update
166
237
  // goes everywhere the Create went.
167
- export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
238
+ // Any object as a post: what a client-to-server Create carries when it is not
239
+ // a Note or a Question — a Web Annotation, say. Stored as sent, with its own
240
+ // context, under this actor; the Create around it is what followers receive.
241
+ // Servers that know the type show it; the rest ignore it, which is the
242
+ // expected outcome.
243
+ //
244
+ // An object already living on this pod (an id under it) is not copied: the
245
+ // Create names it where it is. Anything else is written into the notes
246
+ // container under the client's slug when it is free, else a minted one.
247
+ export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null, also = [], deliverTo = [] } = {}) {
248
+ const { urls } = publisher;
249
+ const priv = visibility === 'private' || visibility === 'direct';
250
+ if (priv) {
251
+ const ready = await publisher.privateReady();
252
+ if (ready !== true) throw new Error(ready);
253
+ }
254
+ const published = new Date().toISOString();
255
+ const container = priv ? urls.privateNotes : urls.notes;
256
+ const pod = publisher.config?.remotePod || urls.base;
257
+ const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
258
+ ? object.id : null;
259
+ const name = await slugFor(publisher, container, wanted, published);
260
+ const addressed = wire.addressing(urls, visibility, visibility === 'direct' ? also : []);
261
+ if (visibility !== 'direct') addressed.cc = [...new Set([...addressed.cc, ...also])];
262
+ let doc = null;
263
+ const id = ownId || container + name;
264
+ if (!ownId) {
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 };
269
+ if (!doc['@context']) doc['@context'] = wire.AS_CTX;
270
+ if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
271
+ await podNotes.write(publisher.remote, id, doc);
272
+ }
273
+ if (!priv) await publisher.recordOutbox(id);
274
+ const row = doc || object;
275
+ publisher.store.addStatus({
276
+ noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
277
+ kind: 'post', slug: name, visibility,
278
+ });
279
+ if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
280
+
281
+ // The Create sits beside the object; for an object living elsewhere on the
282
+ // pod it sits in the notes container, whose ACL is public Read.
283
+ const createId = doc ? wire.createActivityId(id) : wire.createActivityId(container + name);
284
+ const create = {
285
+ '@context': wire.AS_CTX, id: createId, type: 'Create', actor: urls.actor,
286
+ published: row.published || published, to: addressed.to, cc: addressed.cc,
287
+ object: doc || id,
288
+ };
289
+ await podNotes.writeCreate(publisher.remote, create.id, create);
290
+ const contacts = publisher.store.getContacts();
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))];
295
+ await publisher.deliverer.deliverToAll(inboxes, create);
296
+ publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
297
+ return { id, createId, copied: !ownId };
298
+ }
299
+
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 } = {}) {
168
310
  const { urls } = publisher;
169
311
  const updated = new Date().toISOString();
170
312
  const inText = new Set(wire.mentionsIn(content));
@@ -180,24 +322,29 @@ export async function updateNote(publisher, s, { content, spoilerText = null, at
180
322
  // post may carry no slug, but the note id already contains it.
181
323
  const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
182
324
  const slug = s.slug || String(s.noteId).slice(container.length);
325
+ const reply = await replyTarget(publisher, s.inReplyTo, mentions, s.visibility || 'public');
183
326
  const note = wire.noteDoc({
184
327
  urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
185
328
  attachments: atts, mentions, visibility: s.visibility || 'public',
186
- summary: spoilerText, updated, container,
329
+ summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
330
+ also: reply ? [reply.actor] : [],
187
331
  });
188
332
  await podNotes.write(publisher.remote, note.id, note);
189
333
  await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
190
334
  const patched = publisher.store.updateStatus(s.noteId, {
191
335
  content: note.content, text: content, editedAt: updated,
192
336
  spoiler: spoilerText || undefined,
337
+ sensitive: note.sensitive || undefined,
193
338
  attachments: atts.length ? atts : undefined,
194
- mentions: note.tag?.length ? note.tag.map(t => ({ href: t.href, name: t.name })) : undefined,
339
+ mentions: rowTags(note).mentions,
340
+ tags: rowTags(note).tags,
195
341
  });
196
342
  const update = wire.updateActivity(note, urls);
197
343
  const contacts = publisher.store.getContacts();
198
344
  const inboxes = [...new Set([
199
345
  ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
200
346
  ...mentions.map(m => m.inbox),
347
+ reply?.inbox,
201
348
  ].filter(Boolean))];
202
349
  await publisher.deliverer.deliverToAll(inboxes, update);
203
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,9 +80,12 @@ 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.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
85
+ ...(question.sensitive ? { sensitive: true } : {}),
86
+ ...rowTags(question),
84
87
  });
88
+ if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
85
89
 
86
90
  const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
87
91
  await podNotes.writeCreate(publisher.remote, create.id, create);
@@ -163,8 +167,8 @@ export async function republishPoll(publisher, questionId, { closing = null } =
163
167
  }));
164
168
  const question = wire.questionDoc({
165
169
  urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
166
- attachments: [], mentions,
167
- 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,
168
172
  options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
169
173
  closed: poll.closed, votersCount: poll.votersCount || 0,
170
174
  });
@@ -5,6 +5,7 @@
5
5
  import * as wire from '../wire.mjs';
6
6
  import * as podNotes from '../../pod/notes.mjs';
7
7
  import { readLenient } from '../as2.mjs';
8
+ import { rowContent } from './notes.mjs';
8
9
 
9
10
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
10
11
  const REBUILD_MAX_PER_RUN = 200;
@@ -151,13 +152,14 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
151
152
  if (have.has(id) || removed.has(id)) continue;
152
153
  budget--;
153
154
  const note = await podNotes.read(publisher.remote, id).catch(() => null);
154
- if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
155
+ // Any object of ours: a Note, a poll, or whatever a client posted.
156
+ if (!note?.type || note.id !== id || note.attributedTo !== urls.actor) continue;
155
157
  const attachments = wire.attachmentsOf(note);
156
158
  const mentions = (Array.isArray(note.tag) ? note.tag : [])
157
159
  .filter(t => t?.type === 'Mention' && t.href)
158
160
  .map(t => ({ href: t.href, name: t.name }));
159
161
  recovered.push({
160
- noteId: note.id, actor: urls.actor, content: note.content || '',
162
+ noteId: note.id, actor: urls.actor, content: rowContent(note),
161
163
  published: note.published || null,
162
164
  ...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
163
165
  kind: 'post', slug: note.id.slice(urls.notes.length),
@@ -197,3 +199,26 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
197
199
  dropped: Math.max(0, merged.length - kept.length),
198
200
  };
199
201
  }
202
+
203
+ // Own posts the outbox records and the statuses index does not.
204
+ //
205
+ // The index row is the last thing a post writes, and it goes by a debounced
206
+ // PUT: a worker killed or a write refused after the post was answered leaves a
207
+ // post that stands on the pod and reached followers but never shows on its
208
+ // author's own timeline. Run when an agent becomes active. Cheap when nothing
209
+ // is wrong — two cached documents compared, no request — and the capped,
210
+ // merge-only rebuild when something is.
211
+ export async function healStatuses(publisher) {
212
+ const { urls, store } = publisher;
213
+ const own = store.read('outbox.json', [])
214
+ .map(i => (typeof i === 'string' ? i : null))
215
+ .filter(id => id && id.startsWith(urls.notes));
216
+ if (!own.length) return { missing: 0, recovered: 0 };
217
+ const have = new Set(store.getStatuses().map(s => s.noteId));
218
+ const removed = new Set(store.read('outbox-removed.json', []).map(r => r.id));
219
+ const missing = own.filter(id => !have.has(id) && !removed.has(id));
220
+ if (!missing.length) return { missing: 0, recovered: 0 };
221
+ const r = await rebuildStatuses(publisher);
222
+ publisher.log(`timeline index healed: ${missing.length} own post(s) were missing, ${r.recovered} recovered`);
223
+ return { missing: missing.length, recovered: r.recovered };
224
+ }
@@ -204,6 +204,7 @@ export async function reblog(agent, s) {
204
204
  // missing entry, a failed status write would let a retry announce twice.
205
205
  const updated = agent.store.updateStatus(s.noteId, { reblogged: true, announceActivity: act });
206
206
  await agent.publisher.recordOutbox(act);
207
+ if (await agent.store.commit?.() === false) agent.log(`boost sent but its timeline row was refused: ${s.noteId}`);
207
208
  return updated;
208
209
  }
209
210
 
@@ -74,6 +74,7 @@ export function dropFollower(contacts, actor, why) {
74
74
 
75
75
  export class PodStore {
76
76
  constructor({ storage = null, log = console.log } = {}) {
77
+ this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
77
78
  this.storage = storage;
78
79
  this.log = log;
79
80
  this.cache = new Map(); // name → parsed value
@@ -146,6 +147,9 @@ export class PodStore {
146
147
  try { this.cache.set(name, JSON.parse(r.body)); }
147
148
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
148
149
  }
150
+ // Kept for /status: a document skipped here is a timeline or a contact
151
+ // list quietly missing, and the owner should be able to see that.
152
+ this.lastSkipped = skipped;
149
153
  if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
150
154
  this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
151
155
  }
@@ -410,6 +414,15 @@ export class PodStore {
410
414
  // — not from the pod, not from the RDF. Unverified entries are dropped first
411
415
  // now, so a flood can only ever evict itself, and a stranger's genuine
412
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
+
413
426
  addNotification(n) {
414
427
  const all = this.getNotifications();
415
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: [{ rel: 'self', type: 'application/activity+json', href: actor }],
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] || '&quot;'))}" 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
@@ -605,36 +642,50 @@ export function contentHtml(text, mentions = []) {
605
642
  return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
606
643
  }
607
644
 
608
- export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
609
- visibility = 'public', summary = null, updated = null, container = null }) {
610
- const id = (container || urls.notes) + slug;
611
- const who = mentions.map(m => m.actor);
612
- // Mastodon's four shapes. Public and unlisted are world-readable; private
613
- // (followers-only) and direct carry no Public address at all and belong in
614
- // the owner-only container. Mastodon addresses mentions in cc (to, for a
615
- // direct post) and will not notify anyone it does not find there or in the
616
- // tags.
617
- const addressing = {
645
+ // Mastodon's four shapes. Public and unlisted are world-readable; private
646
+ // (followers-only) and direct carry no Public address at all and belong in
647
+ // the owner-only container. Mastodon addresses mentions in cc (to, for a
648
+ // direct post) and will not notify anyone it does not find there or in the
649
+ // tags.
650
+ export function addressing(urls, visibility, who = []) {
651
+ return {
618
652
  public: { to: [PUBLIC], cc: [urls.followers, ...who] },
619
653
  unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
620
654
  private: { to: [urls.followers], cc: who },
621
655
  direct: { to: who, cc: [] },
622
656
  }[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
657
+ }
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.
662
+ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
663
+ visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [] }) {
664
+ const id = (container || urls.notes) + slug;
665
+ const who = mentions.map(m => m.actor);
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) }));
623
669
  const note = {
624
670
  '@context': AS_CTX,
625
671
  id, type: 'Note',
626
672
  attributedTo: urls.actor,
627
- content: contentHtml(content, mentions),
673
+ content: contentHtml(content, mentions, hashtags),
628
674
  published,
629
- to: addressing.to,
630
- cc: addressing.cc,
675
+ to: addressed.to,
676
+ cc: addressed.cc,
631
677
  replies: repliesId(id),
632
678
  };
633
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;
634
683
  if (updated) note.updated = updated; // an edit's own stamp
635
- if (mentions.length) {
636
- note.tag = mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle }));
637
- }
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;
638
689
  if (inReplyTo) note.inReplyTo = inReplyTo;
639
690
  if (attachments?.length) {
640
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': 'application/json', ...securityHeaders(auth, false) });
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
- return json(res, 200, nodeinfoDoc({
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