fedipod 1.8.0 → 1.11.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.
@@ -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.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
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
- doc = { ...object, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
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 = visibility === 'direct' ? []
243
- : [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
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
- export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
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.tag?.length ? note.tag.map(t => ({ href: t.href, name: t.name })) : undefined,
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.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
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
  });
@@ -523,6 +523,9 @@ export async function pinStatus(agent, s, pinned) {
523
523
  if (s.actor !== agent.publisher.urls.actor) throw new Error('not your status');
524
524
  const updated = agent.store.updateStatus(s.noteId, { pinned: !!pinned });
525
525
  await agent.publisher.publishFeatured().catch(e => agent.log?.(`featured: ${e.message}`));
526
+ if (agent.publisher.publishProfilePage) {
527
+ await agent.publisher.publishProfilePage().catch(e => agent.log?.(`profile page: ${e.message}`));
528
+ }
526
529
  agent.publisher.publishProfile?.().catch(() => {});
527
530
  return updated || s;
528
531
  }
@@ -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: [{ 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
 
@@ -193,9 +196,24 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
193
196
  // the actor document. It says who this is and how to follow — the handle to
194
197
  // paste, and the remote-follow control that hands the visitor to their own
195
198
  // server's follow screen.
196
- export function profilePageHtml({ name, address, summary = null, icon = null, kind = 'person' }) {
199
+ // `fields` are the profile's name/value rows, `joined` an ISO date, `pinned`
200
+ // the pinned posts as { content (sanitized HTML), published, url }.
201
+ export function profilePageHtml({ name, address, summary = null, icon = null, image = null,
202
+ fields = [], joined = null, pinned = [], kind = 'person' }) {
197
203
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ ...HTML_ESCAPES, '"': '&quot;' }[c]));
198
204
  const what = kind === 'group' ? 'a group' : 'an account';
205
+ const month = (iso) => {
206
+ const d = new Date(iso);
207
+ return Number.isNaN(d.getTime()) ? null
208
+ : d.toLocaleDateString('en', { month: 'long', year: 'numeric', timeZone: 'UTC' });
209
+ };
210
+ const day = (iso) => {
211
+ const d = new Date(iso);
212
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
213
+ };
214
+ const fieldRows = fields.filter(f => f?.name && f?.value !== undefined && f?.value !== null && String(f.value) !== '');
215
+ const joinedText = joined ? month(joined) : null;
216
+ const pins = pinned.filter(x => x && typeof x.content === 'string');
199
217
  return `<!doctype html>
200
218
  <html lang="en">
201
219
  <head>
@@ -205,14 +223,23 @@ export function profilePageHtml({ name, address, summary = null, icon = null, ki
205
223
  <style>
206
224
  :root { color-scheme: light dark; }
207
225
  body { font: 112.5%/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
208
- img.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; }
226
+ img.header { width: 100%; max-height: 12rem; object-fit: cover; border-radius: 1rem; }
227
+ img.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; margin-top: 1rem; }
209
228
  h1 { margin: .5rem 0 0; }
210
229
  .address { font-size: 1.1rem; user-select: all; }
230
+ dl.fields { display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1rem; margin: 1.5rem 0; }
231
+ dl.fields dt { font-weight: 600; }
232
+ dl.fields dd { margin: 0; overflow-wrap: anywhere; }
233
+ .joined { margin: 1rem 0; }
234
+ section.pinned h2 { font-size: 1.1rem; margin: 2rem 0 .5rem; }
235
+ article.pin { border: 1px solid #8884; border-radius: 1rem; padding: 1rem 1.2rem; margin: 1rem 0; }
236
+ article.pin p:last-child { margin-bottom: 0; }
237
+ article.pin .when { font-size: 1rem; }
211
238
  form { margin-top: 2rem; }
212
239
  label { display: block; margin-bottom: .3rem; }
213
240
  input { font: inherit; padding: .5rem; width: 14rem; max-width: 100%; }
214
241
  button { font: inherit; padding: .5rem 1rem; }
215
- .hint { color: #777; font-size: .9rem; }
242
+ .hint { color: #666; font-size: 1rem; }
216
243
  .err { color: #b00020; }
217
244
  .err:empty { display: none; }
218
245
  @media (prefers-color-scheme: dark) {
@@ -223,10 +250,22 @@ button { font: inherit; padding: .5rem 1rem; }
223
250
  </head>
224
251
  <body>
225
252
  <main>
253
+ ${image ? `<img class="header" src="${esc(image)}" alt="">` : ''}
226
254
  ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
227
255
  <h1>${esc(name)}</h1>
228
256
  <p class="address">${esc(address)}</p>
229
257
  ${summary ? `<div>${summary}</div>` : ''}
258
+ ${fieldRows.length ? `<dl class="fields">
259
+ ${fieldRows.map(f => `<dt>${esc(f.name)}</dt><dd>${esc(f.value)}</dd>`).join('\n')}
260
+ </dl>` : ''}
261
+ ${joinedText ? `<p class="joined">Joined ${esc(joinedText)}</p>` : ''}
262
+ ${pins.length ? `<section class="pinned">
263
+ <h2>Pinned</h2>
264
+ ${pins.map(x => `<article class="pin">
265
+ ${x.content}
266
+ <p class="when">${x.url ? `<a href="${esc(x.url)}">${esc(day(x.published))}</a>` : esc(day(x.published))}</p>
267
+ </article>`).join('\n')}
268
+ </section>` : ''}
230
269
  <p>This is ${what} on the Fediverse. To follow it, paste the address above
231
270
  into the search box of Mastodon or any Fediverse app — or use the form.</p>
232
271
  <form id="follow">
@@ -372,7 +411,7 @@ export function outboxPage(outboxId, n, items) {
372
411
  id: outboxPageId(outboxId, n),
373
412
  type: 'OrderedCollectionPage',
374
413
  partOf: outboxId,
375
- orderedItems: items,
414
+ orderedItems: items.map(outboxWireItem),
376
415
  ...(n > 1 ? { next: outboxPageId(outboxId, n - 1) } : {}),
377
416
  };
378
417
  }
@@ -394,6 +433,13 @@ export function outboxPages(outbox) {
394
433
 
395
434
  export const outboxItemId = (i) => (typeof i === 'string' ? i : i?.id || null);
396
435
 
436
+ // The outbox lists activities. A post is recorded by its object id, which is
437
+ // what every local reader keys on, and published as the Create beside it — a
438
+ // document of its own. An Announce is recorded and published whole.
439
+ export const outboxWireItem = (i) => (typeof i === 'string' ? createActivityId(i) : i);
440
+ export const outboxLocalItem = (i) =>
441
+ (typeof i === 'string' && i.endsWith('-create') ? i.slice(0, -'-create'.length) : i);
442
+
397
443
  // Assign entries to pages, and KEEP the assignment.
398
444
  //
399
445
  // Deriving the pages from position — which is what outboxPages does — makes an
@@ -589,10 +635,37 @@ export function mentionsIn(text) {
589
635
  return [...new Set(String(text).match(MENTION_RE) || [])].map(m => m.slice(1));
590
636
  }
591
637
 
638
+ // #word in the text: letters, digits and underscores with at least one
639
+ // letter, not glued to a word or a path (so a URL's fragment is not a tag).
640
+ export const HASHTAG_RE = /(^|[^\/\w)])#([\p{L}\p{N}_]*[\p{L}_][\p{L}\p{N}_]*)/gu;
641
+
642
+ export function hashtagsIn(text) {
643
+ const seen = new Map();
644
+ for (const m of String(text).matchAll(HASHTAG_RE)) {
645
+ const key = m[2].toLowerCase();
646
+ if (!seen.has(key)) seen.set(key, m[2]);
647
+ }
648
+ return [...seen.values()];
649
+ }
650
+
651
+ // Where a tag's link points: this actor's own page, asked for the tag. A
652
+ // Hashtag's href must be a URL; the name is what servers file it under.
653
+ export function tagHref(urls, name) {
654
+ return urls.profileHtml + '?tag=' + encodeURIComponent(String(name).toLowerCase());
655
+ }
656
+
592
657
  // Escape first, then linkify: escaping cannot introduce an @handle, and the
593
658
  // anchors must survive it. A mention nobody could resolve stays plain text.
594
- export function contentHtml(text, mentions = []) {
659
+ export function contentHtml(text, mentions = [], hashtags = []) {
595
660
  let html = String(text).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
661
+ if (hashtags.length) {
662
+ const byName = new Map(hashtags.filter(h => h?.name && h?.href).map(h => [h.name.toLowerCase(), h.href]));
663
+ html = html.replace(HASHTAG_RE, (all, lead, name) => {
664
+ const href = byName.get(name.toLowerCase());
665
+ if (!href) return all;
666
+ return `${lead}<a href="${String(href).replace(/[&<>"]/g, c => (HTML_ESCAPES[c] || '&quot;'))}" class="mention hashtag" rel="tag">#<span>${name}</span></a>`;
667
+ });
668
+ }
596
669
  for (const m of mentions) {
597
670
  if (!m?.handle || !m?.actor) continue;
598
671
  // The anchor is for PEOPLE — the mentioned actor's advertised page when it
@@ -619,26 +692,36 @@ export function addressing(urls, visibility, who = []) {
619
692
  }[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
620
693
  }
621
694
 
695
+ // `also`: actors addressed without being mentioned — the parent's author on
696
+ // a reply. In cc but not in tag, so their server receives the post and does
697
+ // not notify them. A direct post names its audience in the text alone.
622
698
  export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
623
- visibility = 'public', summary = null, updated = null, container = null }) {
699
+ visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [] }) {
624
700
  const id = (container || urls.notes) + slug;
625
701
  const who = mentions.map(m => m.actor);
626
- const addressed = addressing(urls, visibility, who);
702
+ const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
703
+ const addressed = addressing(urls, visibility, [...who, ...extra]);
704
+ const hashtags = hashtagsIn(content).map(name => ({ name, href: tagHref(urls, name) }));
627
705
  const note = {
628
706
  '@context': AS_CTX,
629
707
  id, type: 'Note',
630
708
  attributedTo: urls.actor,
631
- content: contentHtml(content, mentions),
709
+ content: contentHtml(content, mentions, hashtags),
632
710
  published,
633
711
  to: addressed.to,
634
712
  cc: addressed.cc,
635
713
  replies: repliesId(id),
636
714
  };
637
715
  if (summary) note.summary = summary; // the content warning
716
+ // A content warning is what a receiver hides behind; media marked
717
+ // sensitive without one is hidden the same way.
718
+ if (summary || sensitive) note.sensitive = true;
638
719
  if (updated) note.updated = updated; // an edit's own stamp
639
- if (mentions.length) {
640
- note.tag = mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle }));
641
- }
720
+ const tag = [
721
+ ...mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle })),
722
+ ...hashtags.map(h => ({ type: 'Hashtag', href: h.href, name: '#' + h.name })),
723
+ ];
724
+ if (tag.length) note.tag = tag;
642
725
  if (inReplyTo) note.inReplyTo = inReplyTo;
643
726
  if (attachments?.length) {
644
727
  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
@@ -163,6 +163,7 @@ const credential = await mintCredential({ origin: issuer, email, password, name:
163
163
  const rec = {
164
164
  ...credential,
165
165
  remotePod: pod.endsWith('/') ? pod : pod + '/',
166
+ createdAt: new Date().toISOString(),
166
167
  // The private half goes on THIS machine, exactly as the browser setup does
167
168
  // (lib/setup.mjs). Omitting it here meant the two paths produced different
168
169
  // installs from the same answers: the CLI put the timeline, contacts,
@@ -293,6 +293,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
293
293
  const rec = {
294
294
  ...credential,
295
295
  remotePod: pod.endsWith('/') ? pod : pod + '/',
296
+ createdAt: new Date().toISOString(),
296
297
  ...(root ? { root } : {}),
297
298
  ...(keys === 'pod' ? { keysMode: 'pod' } : {}),
298
299
  // Where the private half lives. Per-machine, like keysMode.
@@ -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: [{ rel: 'self', type: 'application/activity+json', href: actor }],
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, dpop ? { header: dpop, method: request.method, url } : undefined);
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
- return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor }),
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);
@@ -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: 'draft-cavage',
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: 'draft-cavage', keyId: null, actor: null,
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: 'draft-cavage', keyId: null, actor: null,
87
+ verified: false, method, keyId: null, actor: null,
84
88
  reason: 'bad-signature', checks: { signature: false },
85
89
  };
86
90
  }
@@ -137,6 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
137
137
  const rec = {
138
138
  webId,
139
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
140
+ createdAt: new Date().toISOString(),
140
141
  root: 'fedipod/',
141
142
  keysMode: 'pod',
142
143
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.8.0",
3
+ "version": "1.11.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",