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.
- package/lib/client/masto/statuses.mjs +16 -2
- package/lib/connections/bskyfeed.mjs +79 -8
- package/lib/core/intake/activities.mjs +10 -2
- package/lib/core/intake/index.mjs +1 -1
- package/lib/core/intake/notes.mjs +11 -3
- package/lib/core/publisher/notes.mjs +17 -0
- package/lib/core/publisher/questions.mjs +2 -0
- package/lib/core/social.mjs +17 -6
- package/lib/gateway/front-core.mjs +11 -2
- package/lib/pod/inbox.mjs +4 -1
- package/package.json +1 -1
- package/web/app/agent.mjs +5 -1
- package/web/app/dist/sw.js +124 -31
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/site/sw.js +124 -31
|
@@ -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
|
-
|
|
99
|
-
|
|
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
|
|
79
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>' }[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
|
-
...(
|
|
93
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/core/social.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|