fedipod-server 0.13.2 → 0.14.1

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.
@@ -95,8 +95,16 @@ export async function handle(api, ctx) {
95
95
  api.store.setScheduled(sched);
96
96
  return send(200, api.scheduledJson(entry));
97
97
  }
98
- const note = await api.agent.publisher.publishNote(body.status,
99
- { inReplyTo, attachments, visibility, spoilerText });
98
+ let note;
99
+ try {
100
+ note = await api.agent.publisher.publishNote(body.status,
101
+ { inReplyTo, attachments, visibility, spoilerText });
102
+ } catch (e) {
103
+ // A direct message nobody could be found for: the sender's mistake to
104
+ // fix, said in the client, not a fault of the server.
105
+ if (e.code === 'unaddressed') return send(422, { error: e.message });
106
+ throw e;
107
+ }
100
108
  const s = api.store.getStatuses().find(x => x.noteId === note.id);
101
109
  return send(200, api.status(s));
102
110
  }
@@ -182,6 +190,12 @@ export async function handle(api, ctx) {
182
190
  const mContext = /^\/api\/v1\/statuses\/([a-f0-9]+)\/context$/.exec(pathname);
183
191
  if (mContext) {
184
192
  const noteUrl = api.lookup(mContext[1]).s?.noteId;
193
+ // A Bluesky post's conversation lives on Bluesky: ask for it now, so the
194
+ // replies (and their pictures) are in the mirror before the thread is built.
195
+ const opened = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
196
+ if (opened?.kind === 'bsky' && typeof api.agent?.bskyfeed?.mirrorThread === 'function') {
197
+ await api.agent.bskyfeed.mirrorThread(noteUrl);
198
+ }
185
199
  const all = api.store.getStatuses();
186
200
  const byId = new Map(all.map(s => [s.noteId, s]));
187
201
  const ancestors = [];
@@ -18,6 +18,22 @@ export const postUrl = (uri) => {
18
18
  return m ? `https://bsky.app/profile/${m[1]}/post/${m[2]}` : null;
19
19
  };
20
20
 
21
+ // Every place a post view keeps pictures: a plain image post, the media half
22
+ // of a quote-with-media, and a link card's thumbnail. Bluesky's view types
23
+ // differ by shape, not by a flag, so each is read where it lives.
24
+ export function attachmentsOf(post) {
25
+ const e = post?.embed || {};
26
+ const images = [...(e.images || []), ...(e.media?.images || [])]
27
+ .filter(i => i?.fullsize)
28
+ .map(i => ({ url: i.fullsize, mediaType: 'image/jpeg', description: i.alt || '' }));
29
+ const card = e.external?.thumb || e.media?.external?.thumb;
30
+ if (card) {
31
+ const ext = e.external || e.media.external;
32
+ images.push({ url: card, mediaType: 'image/jpeg', description: ext.title || ext.uri || '' });
33
+ }
34
+ return images;
35
+ }
36
+
21
37
  export class BskyFeed {
22
38
  constructor({ store, atproto, log = console.log, onNotification = null }) {
23
39
  Object.assign(this, { store, atproto, log, onNotification });
@@ -73,14 +89,27 @@ export class BskyFeed {
73
89
  }
74
90
 
75
91
  // One Bluesky post into the statuses index. Returns its noteId, new or not.
92
+ // A post seen before is completed, not duplicated: a reply first met as a
93
+ // bare notification record learns its parent and its pictures from the
94
+ // full view when that arrives.
76
95
  _mirrorPost(post, { via = null } = {}) {
77
96
  const noteId = post.uri;
78
- const existing = this.store.getStatuses().some(s => s.noteId === noteId);
79
- if (existing) return { noteId, added: false };
97
+ const inReplyTo = post.record?.reply?.parent?.uri || null;
98
+ const attachments = attachmentsOf(post);
99
+ const existing = this.store.getStatuses().find(s => s.noteId === noteId);
100
+ if (existing) {
101
+ const patch = {
102
+ ...(inReplyTo && !existing.inReplyTo ? { inReplyTo } : {}),
103
+ ...(attachments.length && !existing.attachments?.length ? { attachments } : {}),
104
+ };
105
+ if (Object.keys(patch).length && typeof this.store.updateStatus === 'function') {
106
+ this.store.updateStatus(noteId, patch);
107
+ }
108
+ return { noteId, added: false };
109
+ }
80
110
  const actor = this._rememberAuthor(post.author);
81
111
  if (this.store.isBlocked(actor)) return { noteId, added: false };
82
112
  const text = post.record?.text || '';
83
- const images = post.embed?.images || [];
84
113
  this.store.addStatus({
85
114
  noteId, actor,
86
115
  content: `<p>${text.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))}</p>`,
@@ -89,13 +118,48 @@ export class BskyFeed {
89
118
  ...(post.cid ? { cid: post.cid } : {}),
90
119
  link: postUrl(noteId),
91
120
  ...(via ? { via } : {}),
92
- ...(images.length ? {
93
- attachments: images.map(i => ({ url: i.fullsize, mediaType: 'image/jpeg', description: i.alt || '' })),
94
- } : {}),
121
+ ...(inReplyTo ? { inReplyTo } : {}),
122
+ ...(attachments.length ? { attachments } : {}),
95
123
  });
96
124
  return { noteId, added: true };
97
125
  }
98
126
 
127
+ // The conversation under one post, asked for when a post is opened: the
128
+ // ancestors it hangs from and the replies beneath it, each mirrored with
129
+ // the reply link the thread view is built from. View cache only, like the
130
+ // timeline. Returns how many posts were new; a thread Bluesky will not
131
+ // give (gone, blocked, rate-limited) adds none and is logged, not thrown.
132
+ async mirrorThread(uri, { depth = 6 } = {}) {
133
+ if (!this.atproto?.connected()) return 0;
134
+ if (this.quietUntil && Date.now() < this.quietUntil) return 0;
135
+ let out;
136
+ try {
137
+ out = await this.atproto.xrpc('app.bsky.feed.getPostThread', { params: { uri, depth, parentHeight: 40 } });
138
+ } catch (e) {
139
+ if (e.status === 429 || e.status >= 500) this._backOff(e.status, null);
140
+ else this.log(`bskyfeed: thread ${uri}: ${e.message}`);
141
+ return 0;
142
+ }
143
+ const self = this.atproto.read()?.did;
144
+ const ownMirrors = new Set(this.store.getStatuses().map(s => s.atproto?.uri).filter(Boolean));
145
+ let added = 0;
146
+ const take = (node) => {
147
+ const post = node?.post;
148
+ if (!post?.uri) return; // notFoundPost, blockedPost
149
+ if (post.author?.did === self && ownMirrors.has(post.uri)) return;
150
+ if (this._mirrorPost(post).added) added++;
151
+ };
152
+ for (let up = out?.thread?.parent; up; up = up.parent) take(up);
153
+ const walk = (node, left) => {
154
+ take(node);
155
+ if (left <= 0) return;
156
+ for (const r of node?.replies || []) walk(r, left - 1);
157
+ };
158
+ walk(out?.thread, depth);
159
+ if (added) this.log(`bskyfeed: +${added} from the thread under ${uri}`);
160
+ return added;
161
+ }
162
+
99
163
  async sweep() {
100
164
  if (!this.atproto?.connected()) return;
101
165
  if (this.quietUntil && Date.now() < this.quietUntil) return;
@@ -142,8 +206,15 @@ export class BskyFeed {
142
206
  this.store.addNotification({ type: 'follow', actor, bsky: true });
143
207
  await this.onNotification?.(n, { actor });
144
208
  } else if (n.reason === 'mention' || n.reason === 'reply') {
145
- const record = { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt };
146
- this._mirrorPost(record);
209
+ // The notification carries the bare record no picture URLs and
210
+ // no view. The post view has both; the record is the fallback when
211
+ // Bluesky will not give the view.
212
+ let view = null;
213
+ try {
214
+ const got = await this.atproto.xrpc('app.bsky.feed.getPosts', { params: { uris: n.uri } });
215
+ view = (got?.posts || [])[0] || null;
216
+ } catch { /* the bare record still stands */ }
217
+ this._mirrorPost(view || { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt });
147
218
  this.store.addNotification({ type: 'mention', actor, noteId: n.uri, bsky: true });
148
219
  await this.onNotification?.(n, { actor });
149
220
  }
@@ -197,7 +197,7 @@ export async function onUndo(intake, activity, actor, { trusted = false } = {})
197
197
  intake.log(`unfollowed by ${actor}`);
198
198
  }
199
199
 
200
- export async function onCreate(intake, activity, actor) {
200
+ export async function onCreate(intake, activity, actor, { trusted = false } = {}) {
201
201
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
202
202
  if (!objectId) return 'Create without object id';
203
203
  if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
@@ -219,7 +219,15 @@ export async function onCreate(intake, activity, actor) {
219
219
  const ingested = intake.store.getStatuses()
220
220
  .some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
221
221
  if (!ingested) {
222
- const rejected = await intake.ingestNote(objectId, actor);
222
+ // A verified delivery that carries the note inline is read from that
223
+ // copy: the door checked the sender's signature over these very bytes,
224
+ // and the receipt vouches for this actor. That is the only way a direct
225
+ // or followers-only post can ever be read — it lives in the sender's
226
+ // owner-only container, and fetching it from there answers 401 to
227
+ // everyone. An unverified delivery still fetches, as it always did.
228
+ const inline = trusted && typeof activity.object === 'object' && activity.object?.id === objectId
229
+ ? activity.object : null;
230
+ const rejected = await intake.ingestNote(objectId, actor, { inline });
223
231
  if (rejected) return rejected;
224
232
  }
225
233
  // A group carries its members' posts onward. Only reached from Create, so an
@@ -539,7 +539,7 @@ export class Intake {
539
539
  switch (activity.type) {
540
540
  case 'Follow': return this.onFollow(activity, actor, { trusted });
541
541
  case 'Undo': return this.onUndo(activity, actor, { trusted });
542
- case 'Create': return this.onCreate(activity, actor);
542
+ case 'Create': return this.onCreate(activity, actor, { trusted });
543
543
  case 'Accept': return this.onAccept(activity, actor, { trusted });
544
544
  case 'Like': case 'Announce': {
545
545
  // FEP-1b12: a group Announces the member's whole Create, not the note.
@@ -114,8 +114,10 @@ export function referencesOurObject(intake, activity) {
114
114
  // Shared tail of Create/Announce: deref the note at its origin (never trust
115
115
  // the delivered copy), mirror it into pod RDF + statuses, notify on replies
116
116
  // to our own notes. Returns a rejection reason string, or undefined.
117
- export async function ingestNote(intake, objectId, actor, { via } = {}) {
118
- const note = await intake.fetchAP(objectId);
117
+ export async function ingestNote(intake, objectId, actor, { via, inline = null } = {}) {
118
+ // `inline` is the note as delivered, offered only for a delivery whose
119
+ // signature the door verified (onCreate); otherwise the origin's copy.
120
+ const note = inline || await intake.fetchAP(objectId);
119
121
  if (!note) return `object fetch failed (${objectId})`;
120
122
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
121
123
  const { attachmentsOf, titledContent } = await import('../wire.mjs');
@@ -213,7 +215,13 @@ export async function ingestNote(intake, objectId, actor, { via } = {}) {
213
215
  ...(attachments.length ? { attachments } : {}),
214
216
  ...(via ? { via } : {}),
215
217
  });
216
- if (!followed || (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes))) {
218
+ // A notification for anything addressed to this person: a stranger's
219
+ // post, a reply to one of ours, a direct message, or a post that names
220
+ // us — whoever sent it. A direct message from someone followed used to
221
+ // raise nothing and sat unseen in the private mentions.
222
+ const namesUs = mentions.some((m) => m.href === intake.urls.actor);
223
+ const replyToOurs = !!note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes);
224
+ if (!followed || replyToOurs || direct || namesUs) {
217
225
  intake.store.addNotification({ type: 'mention', actor: author, noteId: note.id });
218
226
  }
219
227
  if (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
@@ -83,6 +83,22 @@ export async function mentionsFor(publisher, content, inReplyTo) {
83
83
  return mentions;
84
84
  }
85
85
 
86
+ // A direct post goes only to the people it names, so one that names nobody
87
+ // the agent can find would be kept in the private container and delivered
88
+ // to no one, with nothing said. Refused instead, before anything is written,
89
+ // with the handles that did not resolve — that is what the sender can act on.
90
+ export function assertDirectAddressed(content, mentions) {
91
+ const wanted = [...new Set(wire.mentionsIn(content))];
92
+ const missing = wanted.filter((h) => !mentions.some((m) => m.handle === h));
93
+ let why = null;
94
+ if (!wanted.length) why = 'a direct message names who it is for — mention them as @name@host';
95
+ else if (missing.length) why = `could not find ${missing.map((h) => '@' + h).join(', ')} — the direct message was not sent`;
96
+ if (!why) return;
97
+ const e = new Error(why);
98
+ e.code = 'unaddressed';
99
+ throw e;
100
+ }
101
+
86
102
  export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
87
103
  const { urls } = publisher;
88
104
  const priv = visibility === 'private' || visibility === 'direct';
@@ -93,6 +109,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
93
109
  const published = new Date().toISOString();
94
110
  const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
95
111
  const mentions = await publisher._mentionsFor(content, inReplyTo);
112
+ if (visibility === 'direct') assertDirectAddressed(content, mentions);
96
113
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
97
114
  visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
98
115
 
@@ -9,6 +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
13
 
13
14
  // How long a poll gathers votes before its Question is rewritten. Every vote
14
15
  // changes a number other servers re-read, and rewriting per vote would make a
@@ -52,6 +53,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
52
53
  const published = new Date().toISOString();
53
54
  const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
54
55
  const mentions = await publisher._mentionsFor(content, inReplyTo);
56
+ if (visibility === 'direct') assertDirectAddressed(content, mentions);
55
57
  const poll = {
56
58
  multiple: !!multiple,
57
59
  expiresAt: expiresAt || null,
@@ -15,7 +15,14 @@ import { dropFollower } from './store.mjs';
15
15
  // belongs behind the same SSRF guard as every other outbound request. fedify's
16
16
  // lookup did not go through ours. https only, because a handle resolved over
17
17
  // plaintext is a handle anyone on the path can answer for.
18
- export async function lookupWebFinger(acct) {
18
+ //
19
+ // `fetchImpl`, when given, makes the request instead of safeFetch: the agent's
20
+ // own remote read, which in the browser build is the relay — a server-side
21
+ // fetch, so the lookup does not depend on the far pod host answering a
22
+ // browser's cross-origin request. Every other remote read there goes the
23
+ // same way; WebFinger went direct and was the one lookup that could fail
24
+ // while the rest worked.
25
+ export async function lookupWebFinger(acct, { fetchImpl = null } = {}) {
19
26
  const clean = String(acct || '').replace(/^acct:/, '');
20
27
  const at = clean.lastIndexOf('@');
21
28
  if (at < 1) return null;
@@ -23,9 +30,8 @@ export async function lookupWebFinger(acct) {
23
30
  if (!host || /[/\\?#]/.test(host)) return null;
24
31
  const { safeFetch, readCapped } = await import('../shared/safefetch.mjs');
25
32
  const url = `https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${clean}`)}`;
26
- const res = await safeFetch(url, {
27
- headers: { accept: 'application/jrd+json, application/json' },
28
- }).catch(() => null);
33
+ const headers = { accept: 'application/jrd+json, application/json' };
34
+ const res = await (fetchImpl ? fetchImpl(url, { headers }) : safeFetch(url, { headers })).catch(() => null);
29
35
  if (!res || res.status >= 400) return null;
30
36
  let jrd;
31
37
  try { jrd = JSON.parse(await readCapped(res, 256 * 1024)); } catch { return null; }
@@ -80,13 +86,18 @@ export async function resolveHandle(agent, handle) {
80
86
  const clean = String(handle || '').replace(/^@/, '');
81
87
  if (!/^[^@]+@[^@]+$/.test(clean)) throw new Error('handle must look like user@host');
82
88
  if (agent.store.isBlocked('https://' + clean.split('@')[1] + '/')) throw new Error('domain is blocked');
83
- const jrd = await lookupWebFinger('acct:' + clean);
89
+ // Through the agent's own remote read where it has one — the relay, in the
90
+ // browser build — and directly otherwise.
91
+ const remote = agent.intake?.deliverer?.signedFetch
92
+ ? (u, i) => agent.intake.deliverer.signedFetch(u, i) : null;
93
+ const lookup = (a) => lookupWebFinger(a, { fetchImpl: remote });
94
+ const jrd = await lookup('acct:' + clean);
84
95
  const self = selfLink(jrd);
85
96
  if (!self?.href) throw new Error(`webfinger found no actor for ${clean}`);
86
97
  const doc = await agent.intake.fetchAP(self.href);
87
98
  if (!doc?.id) throw new Error(`actor document unusable for ${clean}`);
88
99
  if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
89
- await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc);
100
+ await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc, lookup);
90
101
  // Counts live in the collections, not the actor document, so a client asking
91
102
  // "who is this" would otherwise be told everyone has none. Two GETs, and only
92
103
  // when someone asked by name — never on the drain path.
@@ -429,7 +429,14 @@ async function route(request, ctx) {
429
429
  // Re-attaching your own pod — a retry, a re-provision, a second browser — is
430
430
  // idempotent: it updates the row in place and keeps the same door secret.
431
431
  const prior = await ctx.lookup(key);
432
- if (prior && prior.podHome !== podHome) return j(409, { error: 'that name is taken' });
432
+ // The same account on the same pod may correct where its tree lives — a
433
+ // row made by an older sign-up named the pod root, and every delivery
434
+ // to it was written where nothing reads. A different pod is still taken.
435
+ const sameOwner = (a, b) => { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } };
436
+ if (prior && prior.podHome !== podHome
437
+ && !(prior.webId && prior.webId === webid && sameOwner(prior.podHome, podHome))) {
438
+ return j(409, { error: 'that name is taken' });
439
+ }
433
440
  // Re-attaching is idempotent — a retry, a re-provision, a second browser —
434
441
  // and it hands back the EXISTING receipt secret. So it has to be the same
435
442
  // person: matching podHome was enough on a subdomain server and, on a
@@ -588,8 +595,10 @@ async function route(request, ctx) {
588
595
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
589
596
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
590
597
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
591
- const { status } = await handleDelivery(request, identFor(rec, policy),
598
+ const { status, reason } = await handleDelivery(request, identFor(rec, policy),
592
599
  { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl });
600
+ // One line per delivery, so "did it arrive at the door" has an answer.
601
+ console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
593
602
  return { status, headers: {}, body: '' };
594
603
  }
595
604
 
package/lib/pod/inbox.mjs CHANGED
@@ -22,10 +22,13 @@ const RECEIPT_CT = 'application/json';
22
22
  *
23
23
  * @returns {Promise<boolean>} whether it landed
24
24
  */
25
- export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch } = {}) {
25
+ export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null } = {}) {
26
26
  const headers = { 'content-type': contentType,
27
27
  ...(appendToken ? { authorization: `Bearer ${appendToken}` } : {}) };
28
28
  const r = await fetchImpl(url, { method: 'PUT', headers, body }).catch(() => null);
29
+ // The pod's answer, for a caller that keeps a log: a refused write is
30
+ // otherwise a bare "failed" with the status thrown away.
31
+ report?.(r ? r.status : 0);
29
32
  return !!r && r.status < 400;
30
33
  }
31
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/web/app/agent.mjs CHANGED
@@ -25,7 +25,7 @@ import { BrowserAtproto } from './atproto-browser.mjs';
25
25
  import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
26
26
  import { BrowserFediAccounts } from './fediacct-browser.mjs';
27
27
  import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
28
- import { followActor, unfollowActor } from '../../lib/core/social.mjs';
28
+ import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
29
29
  import { ImportWorker } from '../../lib/connections/import.mjs';
30
30
 
31
31
  // The authorities this identity answers on: exactly one, this origin. The Node
@@ -212,6 +212,10 @@ export class BrowserAgent {
212
212
  this.publisher = new Publisher({
213
213
  config: this.store.getConfig(), remote: this.remote, store: this.store,
214
214
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
215
+ // Who a post names, resolved — the same lookup the installed agent
216
+ // gives its publisher. Without it no mention from the browser ever
217
+ // resolved: a direct message went to nobody, a mention notified no one.
218
+ resolveMention: (h) => resolveHandle(this, h),
215
219
  });
216
220
 
217
221
  // The Bluesky connection, stamped to this actor. The same client the Node