fedipod-server 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// notes.mjs — a note on its way in: whether it concerns us, fetching it at
|
|
2
|
+
// its origin, filing it, the replies collection on our own posts, and the
|
|
3
|
+
// two ways a held post leaves again (forget, retract). Also the §7.1.2
|
|
4
|
+
// forward of a reply into one of our threads to our followers.
|
|
5
|
+
|
|
6
|
+
import * as podNotes from '../../pod/notes.mjs';
|
|
7
|
+
import { PUBLIC } from '../wire.mjs';
|
|
8
|
+
import * as polls from '../polls.mjs';
|
|
9
|
+
import { httpOnly, emojisOf, pollOf, isContentType, authorOf, MAX_MENTIONS, MAX_URL_CHARS, MAX_REPLIES_RECORDED, MAX_FORWARDED, FORWARDABLE, FORWARD_TYPES } from './activity.mjs';
|
|
10
|
+
|
|
11
|
+
// Does this activity/note concern us at all? Either it comes from someone
|
|
12
|
+
// we follow, or it names us (to/cc, mention tag) or replies to one of our
|
|
13
|
+
// notes. Anything else is a stranger blasting inboxes — refuse it before
|
|
14
|
+
// spending a dereference on it.
|
|
15
|
+
export function concernsUs(intake, doc, actor) {
|
|
16
|
+
if (intake.store.getContacts().following.some(f => f.actor === actor && f.accepted)) return true;
|
|
17
|
+
const audience = []
|
|
18
|
+
.concat(doc?.to || [], doc?.cc || [], doc?.bto || [], doc?.bcc || [], doc?.audience || [])
|
|
19
|
+
.map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
|
|
20
|
+
if (audience.includes(intake.urls.actor) || audience.includes(intake.urls.followers)) return true;
|
|
21
|
+
const tagged = [].concat(doc?.tag || [])
|
|
22
|
+
.some(t => t?.type === 'Mention' && (t.href === intake.urls.actor || t.name?.includes(intake.urls.actor)));
|
|
23
|
+
if (tagged) return true;
|
|
24
|
+
const inReplyTo = typeof doc?.inReplyTo === 'string' ? doc.inReplyTo : doc?.inReplyTo?.id;
|
|
25
|
+
if (!inReplyTo) return false;
|
|
26
|
+
if (String(inReplyTo).startsWith(intake.urls.notes)) return true;
|
|
27
|
+
// A group also owns the conversation under anything it carried. Without
|
|
28
|
+
// this, a reply that lost the group's mention on its way round the
|
|
29
|
+
// fediverse is refused, and the thread breaks for everyone who was only
|
|
30
|
+
// ever following the group.
|
|
31
|
+
return intake.config.kind === 'group'
|
|
32
|
+
&& intake.store.getStatuses().some(s => s.noteId === String(inReplyTo));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// §7.1.2 Forwarding from the inbox. A reply into one of our threads reaches
|
|
36
|
+
// only the servers the replier's server chose to deliver to — never our
|
|
37
|
+
// followers on servers it has never heard of. As the actor those followers
|
|
38
|
+
// follow, WE close that gap: an activity addressed to our followers collection
|
|
39
|
+
// that names one of our objects is re-delivered to our followers' inboxes.
|
|
40
|
+
//
|
|
41
|
+
// Only what was addressed to the followers COLLECTION is carried — bto/bcc are
|
|
42
|
+
// never read here, so a direct message (addressed to a person) never qualifies
|
|
43
|
+
// and is never rebroadcast. And this runs only after handle() accepted the
|
|
44
|
+
// activity, so anything blocked or muted was already refused upstream and is
|
|
45
|
+
// never forwarded.
|
|
46
|
+
export async function maybeForward(intake, activity) {
|
|
47
|
+
if (!activity || typeof activity !== 'object') return;
|
|
48
|
+
if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
|
|
49
|
+
// Forwarding signs somebody else's activity with OUR key and pushes it to
|
|
50
|
+
// every follower we have. That is a lot to do on the word of an unsigned
|
|
51
|
+
// POST, so it is narrowed three ways:
|
|
52
|
+
//
|
|
53
|
+
// 1. Only the types whose object this drain actually DEREFERENCED at the
|
|
54
|
+
// author's own origin. A Create/Update/Delete went through onCreate /
|
|
55
|
+
// onUpdate / onDelete, which fetch and check the author; a Like, an
|
|
56
|
+
// Announce or an Undo is believed on the envelope alone, so relaying
|
|
57
|
+
// one made us a signed relay for anything a stranger cared to write.
|
|
58
|
+
// 2. The actor has to be someone we already know of. A complete stranger
|
|
59
|
+
// addressing our followers collection is not a conversation we are
|
|
60
|
+
// party to.
|
|
61
|
+
// 3. A budget per drain, so a flood cannot turn one sweep into thousands
|
|
62
|
+
// of outbound deliveries under our signature.
|
|
63
|
+
if (!FORWARD_TYPES.has(activity.type)) return;
|
|
64
|
+
try {
|
|
65
|
+
const audience = []
|
|
66
|
+
.concat(activity.to || [], activity.cc || [], activity.audience || [])
|
|
67
|
+
.map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
|
|
68
|
+
if (!audience.includes(intake.urls.followers)) return; // not for our followers
|
|
69
|
+
if (!intake._referencesOurObject(activity)) return; // not into a thread of ours
|
|
70
|
+
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
71
|
+
if (actor === intake.urls.actor) return; // our own; nothing to forward
|
|
72
|
+
if (!intake.known(actor)) {
|
|
73
|
+
intake.log(`not forwarding ${activity.type} from ${actor}: nobody we know of`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (intake._forwardBudget <= 0) {
|
|
77
|
+
intake.log(`not forwarding ${activity.type}: this drain's forwarding budget is spent`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const id = typeof activity.id === 'string' ? activity.id : null;
|
|
81
|
+
if (!id) return;
|
|
82
|
+
const forwarded = intake.store.read('forwarded.json', []);
|
|
83
|
+
if (forwarded.includes(id)) return; // already done
|
|
84
|
+
|
|
85
|
+
const inboxes = [...new Set(intake.store.getContacts().followers
|
|
86
|
+
.filter(f => !f.bsky) // Bluesky members are not AP inboxes
|
|
87
|
+
.map(f => f.sharedInbox || f.inbox)
|
|
88
|
+
.filter(Boolean))];
|
|
89
|
+
if (!inboxes.length) return;
|
|
90
|
+
intake._forwardBudget -= 1;
|
|
91
|
+
await intake.deliverer.deliverToAll(inboxes, activity);
|
|
92
|
+
intake.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
|
|
93
|
+
intake.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
intake.log(`inbox forwarding: ${e.message}`); // never stalls the drain
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The "objects owned by the server" half of §7.1.2: does the activity reply
|
|
100
|
+
// to, like, boost or otherwise name one of our own objects?
|
|
101
|
+
export function referencesOurObject(intake, activity) {
|
|
102
|
+
const refs = [];
|
|
103
|
+
const add = (v) => { const id = typeof v === 'string' ? v : v?.id; if (id) refs.push(String(id)); };
|
|
104
|
+
const obj = activity.object;
|
|
105
|
+
if (obj && typeof obj === 'object') add(obj.inReplyTo); // a reply's parent
|
|
106
|
+
add(activity.inReplyTo);
|
|
107
|
+
add(activity.object); // a Like/Announce of our note
|
|
108
|
+
add(activity.target);
|
|
109
|
+
if (refs.some(r => r.startsWith(intake.urls.notes))) return true;
|
|
110
|
+
return intake.config.kind === 'group'
|
|
111
|
+
&& refs.some(r => intake.store.getStatuses().some(s => s.noteId === r));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Shared tail of Create/Announce: deref the note at its origin (never trust
|
|
115
|
+
// the delivered copy), mirror it into pod RDF + statuses, notify on replies
|
|
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);
|
|
119
|
+
if (!note) return `object fetch failed (${objectId})`;
|
|
120
|
+
if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
|
|
121
|
+
const { attachmentsOf, titledContent } = await import('../wire.mjs');
|
|
122
|
+
const attachments = attachmentsOf(note);
|
|
123
|
+
const content = titledContent(note); // hostile markup never reaches pod or client
|
|
124
|
+
// The delivering actor was checked on arrival; the author is only known once
|
|
125
|
+
// the note is dereferenced. authorOf refuses an author the note's own origin
|
|
126
|
+
// does not vouch for — see its comment; this is where a forged attribution
|
|
127
|
+
// would otherwise become a timeline entry, a pod document, and for a group a
|
|
128
|
+
// signed Announce to every member.
|
|
129
|
+
const author = authorOf(note, actor);
|
|
130
|
+
if (!author) return `object names an author its origin does not vouch for (${objectId})`;
|
|
131
|
+
// This is the check that catches a blocked actor reaching us through
|
|
132
|
+
// somebody else's boost, or through a hashtag feed.
|
|
133
|
+
if (intake.store.isBlocked(author)) return `blocked author (${author})`;
|
|
134
|
+
|
|
135
|
+
// The ENVELOPE said this concerns us. The envelope is a document a stranger
|
|
136
|
+
// wrote: onCreate reads addressing off the delivered copy to decide whether
|
|
137
|
+
// fetching is worth it, and nothing re-asked the question of the copy that
|
|
138
|
+
// came back from the author's own server. So anyone could take any public
|
|
139
|
+
// post, address the delivery to us, and have it filed as "X mentioned you"
|
|
140
|
+
// — or, to a group, have a member's post carried to every follower when the
|
|
141
|
+
// member never sent it there.
|
|
142
|
+
//
|
|
143
|
+
// A boost is exempt (`via`): someone we follow deliberately putting a post
|
|
144
|
+
// in front of us is the whole point, and the note will not address us.
|
|
145
|
+
if (!via && !intake.concernsUs(note, author)) {
|
|
146
|
+
return `the note its own server serves does not address us (${objectId})`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// An answer to one of our polls is a number on a document, not a post. It
|
|
150
|
+
// arrives as an ordinary reply naming an option and carrying nothing else,
|
|
151
|
+
// so filing it as one would put a blank entry in the thread and ring the
|
|
152
|
+
// owner once per voter. Counted or refused — a second answer, an option we
|
|
153
|
+
// do not offer, a poll already shut — it stops here either way.
|
|
154
|
+
const asked = note.inReplyTo && intake.store.getStatuses()
|
|
155
|
+
.find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
|
|
156
|
+
if (asked && polls.isVoteShape(note)) {
|
|
157
|
+
const counted = await intake.publisher.recordVote(asked.noteId, author, note.name)
|
|
158
|
+
.catch(e => { intake.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
|
|
159
|
+
intake.log(counted
|
|
160
|
+
? `vote counted (${note.name}): ${asked.noteId}`
|
|
161
|
+
: `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Anyone can Append to a public inbox, so arriving is not the same as
|
|
166
|
+
// belonging in the home timeline. Follow Mastodon's split: people you
|
|
167
|
+
// follow (and their boosts) are HOME; anyone else is a MENTION — kept,
|
|
168
|
+
// notified, readable in the Mentions view, but out of the timeline, and
|
|
169
|
+
// mirror-only so unsolicited content never accumulates in the pod.
|
|
170
|
+
// A group's people are its FOLLOWERS — it follows nobody. Reading the
|
|
171
|
+
// following list for one filed every member's post as a stranger's mention,
|
|
172
|
+
// so nothing reached the pod RDF and each post raised a notification.
|
|
173
|
+
const contacts = intake.store.getContacts();
|
|
174
|
+
const known = intake.config.kind === 'group'
|
|
175
|
+
? contacts.followers.some(f => f.actor === author)
|
|
176
|
+
: contacts.following.some(f => f.actor === author && f.accepted);
|
|
177
|
+
// Someone in a group you are in is not a stranger: their reply belongs in
|
|
178
|
+
// the room, not in the drawer of unsolicited mail. Whose word this is on
|
|
179
|
+
// is the group's — its published membership — so the group's own door
|
|
180
|
+
// decides who gets in.
|
|
181
|
+
const followed = via || known || (!known && await intake.isCoMember(author));
|
|
182
|
+
const kind = followed ? 'timeline' : 'mention';
|
|
183
|
+
|
|
184
|
+
// Mastodon carries a thread's mentions into every reply, which is the only
|
|
185
|
+
// reason a reply ever reaches a group. Keep them so our composer can too.
|
|
186
|
+
const mentions = [].concat(note.tag || [])
|
|
187
|
+
.filter(t => t?.type === 'Mention' && t.href && t.name)
|
|
188
|
+
.slice(0, MAX_MENTIONS)
|
|
189
|
+
.map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
|
|
190
|
+
.filter(m => m.href);
|
|
191
|
+
const emojis = emojisOf(note);
|
|
192
|
+
const poll = pollOf(note);
|
|
193
|
+
// Explicitly addressed, but to nobody public and to no followers
|
|
194
|
+
// collection: a direct message, which belongs to the conversations view
|
|
195
|
+
// rather than a timeline. A note with no addressing at all is NOT direct —
|
|
196
|
+
// some servers omit to/cc, and vanishing from home is the wrong reading.
|
|
197
|
+
const audience = [].concat(note.to || [], note.cc || []).map(String);
|
|
198
|
+
const direct = audience.length > 0
|
|
199
|
+
&& !audience.includes(PUBLIC) && !audience.some(a => a.endsWith('/followers'));
|
|
200
|
+
// Addressed to less than the world: whatever else happens to it, a group
|
|
201
|
+
// must never widen its audience by carrying it.
|
|
202
|
+
const nonPublic = audience.length > 0 && !audience.includes(PUBLIC);
|
|
203
|
+
intake.store.addStatus({
|
|
204
|
+
noteId: note.id, actor: author, content,
|
|
205
|
+
published: note.published, inReplyTo: note.inReplyTo, kind,
|
|
206
|
+
...(direct ? { direct: true } : {}),
|
|
207
|
+
...(nonPublic ? { nonPublic: true } : {}),
|
|
208
|
+
// The author's content warning, shown as one: plain text only.
|
|
209
|
+
...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
|
|
210
|
+
...(poll ? { poll } : {}),
|
|
211
|
+
...(emojis.length ? { emojis } : {}),
|
|
212
|
+
...(mentions.length ? { mentions } : {}),
|
|
213
|
+
...(attachments.length ? { attachments } : {}),
|
|
214
|
+
...(via ? { via } : {}),
|
|
215
|
+
});
|
|
216
|
+
if (!followed || (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes))) {
|
|
217
|
+
intake.store.addNotification({ type: 'mention', actor: author, noteId: note.id });
|
|
218
|
+
}
|
|
219
|
+
if (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
|
|
220
|
+
await intake.addReply(String(note.inReplyTo), note.id)
|
|
221
|
+
.catch(e => intake.log(`replies collection: ${e.message}`));
|
|
222
|
+
}
|
|
223
|
+
intake.log(`${kind}: ${note.id}${via ? ` (boosted by ${via})` : ''}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Drop a post we were holding. A group that carried it also unsays its own
|
|
227
|
+
// Announce — forwarding the author's Delete would be signed by us and not by
|
|
228
|
+
// them, which receivers are right to refuse.
|
|
229
|
+
// `collect` batches the outbox side: retract pushes the Announce id onto it
|
|
230
|
+
// instead of republishing, and the caller writes once for all of them.
|
|
231
|
+
export async function forget(intake, s, { collect = null } = {}) {
|
|
232
|
+
if (s.announceActivity) {
|
|
233
|
+
await intake.retract(s.noteId, { collect }).catch(e => intake.log(`retract: ${e.message}`));
|
|
234
|
+
}
|
|
235
|
+
intake.store.removeStatus(s.noteId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Undo an Announce this group made. Shared with the operator's `retract`.
|
|
239
|
+
export async function retract(intake, noteId, { collect = null } = {}) {
|
|
240
|
+
const s = intake.store.getStatuses().find(x => x.noteId === noteId);
|
|
241
|
+
if (!s) throw new Error('no such post');
|
|
242
|
+
// A Bluesky carry is a repost, and unsaying it is deleting the repost.
|
|
243
|
+
if (s.repostUri) {
|
|
244
|
+
if (!intake.bskyGroup) throw new Error('no bluesky account connected');
|
|
245
|
+
return intake.bskyGroup.retract(s);
|
|
246
|
+
}
|
|
247
|
+
if (!s.announceActivity) throw new Error('that post was never carried');
|
|
248
|
+
const { undoActivity } = await import('../wire.mjs');
|
|
249
|
+
const inboxes = intake.announceTargets(s.actor);
|
|
250
|
+
await intake.deliverer.deliverToAll(inboxes,
|
|
251
|
+
undoActivity({ urls: intake.urls, activity: s.announceActivity, serial: intake.serial++ }));
|
|
252
|
+
if (collect) collect.push(s.announceActivity.id);
|
|
253
|
+
else await intake.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
|
|
254
|
+
intake.store.updateStatus(noteId, {
|
|
255
|
+
announcedAt: undefined, announceActivity: undefined, retractedAt: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
return { ok: true, noteId, inboxes: inboxes.length };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Read-modify-write, and the drain is serialized, so two replies in one sweep
|
|
261
|
+
// do not race. Nothing else writes this document.
|
|
262
|
+
// MAX_REPLIES_RECORDED caps the collection. It is a discovery aid — a client
|
|
263
|
+
// reading a thread — and the statuses index is what actually holds the
|
|
264
|
+
// replies, so dropping the oldest costs a hop, not a record.
|
|
265
|
+
export async function addReply(intake, parentId, replyId) {
|
|
266
|
+
// The parent has to be a post we actually made. The only check used to be
|
|
267
|
+
// that the id started with our notes prefix, and `inReplyTo` is read off a
|
|
268
|
+
// document at the sender's own origin — so a stranger could name a note we
|
|
269
|
+
// never wrote, and we would CREATE a document on the pod at a URL of their
|
|
270
|
+
// choosing and then grow it, one whole re-PUT per reply, with no cap. Four
|
|
271
|
+
// pod requests each and bytes quadratic in the number of replies.
|
|
272
|
+
if (!intake.store.getStatuses().some(s => s.noteId === parentId && s.kind === 'post')) {
|
|
273
|
+
intake.log(`reply names ${parentId}, which is not a post of ours — not recorded`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const { repliesId, collection } = await import('../wire.mjs');
|
|
277
|
+
const url = repliesId(parentId);
|
|
278
|
+
// Deliberately NOT caught: a read we could not make is not an empty
|
|
279
|
+
// collection, and rewriting on top of one erases every reply already
|
|
280
|
+
// recorded. getJson returns null only for a genuine 404 — the document does
|
|
281
|
+
// not exist yet — and throws otherwise, which the caller logs and retries.
|
|
282
|
+
const cur = await podNotes.readReplies(intake.remote, url);
|
|
283
|
+
const items = Array.isArray(cur?.items) ? cur.items : [];
|
|
284
|
+
if (items.includes(replyId)) return;
|
|
285
|
+
items.push(replyId);
|
|
286
|
+
await podNotes.writeReplies(intake.remote, url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
|
|
287
|
+
intake.log(`reply recorded on ${parentId}`);
|
|
288
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// verify.mjs — how the drain decides what to believe: a signed fetch of the
|
|
2
|
+
// claimed document at its own origin, what local state already knows of, and
|
|
3
|
+
// the receipt a gateway left beside a delivery.
|
|
4
|
+
|
|
5
|
+
import * as podInbox from '../../pod/inbox.mjs';
|
|
6
|
+
import { readCapped } from '../../shared/safefetch.mjs';
|
|
7
|
+
import { isActorType, sameOrigin, ACCEPT_AP } from './activity.mjs';
|
|
8
|
+
import { readLenient } from '../as2.mjs';
|
|
9
|
+
|
|
10
|
+
export async function fetchAP(intake, url) {
|
|
11
|
+
const res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } });
|
|
12
|
+
if (res.status >= 400) return null;
|
|
13
|
+
// Remote servers are untrusted: read with a byte budget rather than
|
|
14
|
+
// letting res.json() buffer whatever they choose to send.
|
|
15
|
+
const { readCapped } = await import('../../shared/safefetch.mjs');
|
|
16
|
+
// Plenty of servers answer 200 text/html however politely we ask for AS2 —
|
|
17
|
+
// people reply to ordinary web pages, and their id is that page. Say so,
|
|
18
|
+
// rather than handing the HTML to JSON.parse and logging the parser's
|
|
19
|
+
// complaint about an unexpected `<`.
|
|
20
|
+
// Not logged: people reply to ordinary web pages, so the reply's object id
|
|
21
|
+
// is that page and this is the expected answer, not a fault. Only a server
|
|
22
|
+
// that CLAIMS to be sending JSON and then does not is worth a line.
|
|
23
|
+
const ct = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
24
|
+
if (ct && !ct.endsWith('json')) return null;
|
|
25
|
+
let doc = null;
|
|
26
|
+
try {
|
|
27
|
+
const read = await readLenient(await readCapped(res));
|
|
28
|
+
if (read.degraded) intake.log(`fetch ${url} grounded to read: ${read.degraded}`);
|
|
29
|
+
doc = read.view ?? read.doc;
|
|
30
|
+
} catch (e) { intake.log(`fetch ${url}: unreadable — ${e.message}`); return null; }
|
|
31
|
+
if (!doc) { intake.log(`fetch ${url}: unreadable as JSON`); return null; }
|
|
32
|
+
// A document is only evidence about its OWN origin.
|
|
33
|
+
//
|
|
34
|
+
// signedFetch follows redirects and hands back only the final response, and
|
|
35
|
+
// every caller checks `doc.id` against the URL it ASKED for — so an open
|
|
36
|
+
// redirector on a victim's origin let an attacker's server answer for a
|
|
37
|
+
// victim's URL and be believed by that check.
|
|
38
|
+
//
|
|
39
|
+
// Only a redirect that CROSSED AN ORIGIN is refused here. A same-origin
|
|
40
|
+
// redirect is ordinary (canonicalisation, trailing slashes), and a document
|
|
41
|
+
// fetched with no redirect at all is still returned whatever it claims —
|
|
42
|
+
// the callers reject it on id, and they say something more useful about it
|
|
43
|
+
// than this could ("actor id mismatch", "object not verifiable content").
|
|
44
|
+
const landed = res.finalUrl || url;
|
|
45
|
+
if (!sameOrigin(landed, url) && doc && doc.id && !sameOrigin(doc.id, landed)) {
|
|
46
|
+
intake.log(`fetch ${url}: redirected to ${landed}, which is not where ${doc.id} lives — refused`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
// Every actor type, not just Person. A Group was fetched, used and thrown
|
|
50
|
+
// away, so nothing knew its preferredUsername — and a client rendering it
|
|
51
|
+
// fell back to the last path segment of the actor URL, which is the literal
|
|
52
|
+
// word `actor`. That is where @actor@host came from.
|
|
53
|
+
// Under the id the document CLAIMS, but only when its own origin vouches
|
|
54
|
+
// for that id. A stranger's actor document naming someone else's id used to
|
|
55
|
+
// overwrite that actor's cached name, bio, avatar and Person/Group flag —
|
|
56
|
+
// one appended Follow was enough, and the id-mismatch checks in onFollow
|
|
57
|
+
// and ingestNote both run after this line and never undid it.
|
|
58
|
+
//
|
|
59
|
+
// Same origin rather than exact equality: signedFetch follows redirects
|
|
60
|
+
// without reporting where it landed, so a server that redirects its own
|
|
61
|
+
// canonical actor URL would otherwise stop being cached at all.
|
|
62
|
+
if (isActorType(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
|
|
63
|
+
intake.store.cacheActor(doc.id, doc);
|
|
64
|
+
}
|
|
65
|
+
return doc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Have we ever heard of this actor or object? Answered entirely from local
|
|
69
|
+
// state, so asking costs nothing. It is what stops a stranger's Delete or
|
|
70
|
+
// Update — of which Mastodon broadcasts a great many, and of which anyone at
|
|
71
|
+
// all can Append one — turning into a signed request to a host they chose.
|
|
72
|
+
export function known(intake, id) {
|
|
73
|
+
const c = intake.store.getContacts();
|
|
74
|
+
return c.followers.some(f => f.actor === id)
|
|
75
|
+
|| c.following.some(f => f.actor === id)
|
|
76
|
+
|| intake.store.getStatuses().some(s => s.noteId === id || s.actor === id)
|
|
77
|
+
|| !!intake.store.getActors()[id];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Returns a rejection reason string, or undefined when handled.
|
|
81
|
+
// The gateway's shared HMAC secret, or null when no gateway is configured.
|
|
82
|
+
// Its absence is what makes the whole receipt path dormant by default.
|
|
83
|
+
export function gatewaySecret(intake) {
|
|
84
|
+
return intake.store.getConfig()?.gateway?.hmacSecret || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Shadow-mode measurement: how much real traffic actually verified. The one
|
|
88
|
+
// number an operator needs before trusting receipts. Only touched while a
|
|
89
|
+
// gateway is configured, so it costs a non-gateway install nothing.
|
|
90
|
+
export function bumpGatewayStat(intake, verified) {
|
|
91
|
+
const s = intake.store.read('gateway-stats.json', { verified: 0, unverified: 0 });
|
|
92
|
+
if (verified) s.verified++; else s.unverified++;
|
|
93
|
+
s.lastAt = new Date().toISOString();
|
|
94
|
+
intake.store.write('gateway-stats.json', s);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read and authenticate the receipt a gateway wrote beside an inbox item.
|
|
98
|
+
// Returns the receipt object only when its HMAC verifies against our secret;
|
|
99
|
+
// null otherwise (no gateway, no receipt, a stranger's forged one, or a read
|
|
100
|
+
// failure) — and null means "unverified", the pre-gateway behavior.
|
|
101
|
+
export async function readReceipt(intake, itemUrl) {
|
|
102
|
+
const secret = intake.gatewaySecret();
|
|
103
|
+
if (!secret) return null;
|
|
104
|
+
try {
|
|
105
|
+
const { readCapped: cap } = await import('../../shared/safefetch.mjs');
|
|
106
|
+
const receipt = await podInbox.readDeliveryReceipt(intake.remote, itemUrl, { maxBytes: 64 * 1024, readCapped: cap });
|
|
107
|
+
if (!receipt) return null;
|
|
108
|
+
const { verifyReceipt } = await import('../../gateway/httpsig.mjs');
|
|
109
|
+
return verifyReceipt(receipt, secret) ? receipt : null;
|
|
110
|
+
} catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Whether a receipt says anything about THIS actor. Verified-and-about-someone
|
|
114
|
+
// -else is worth exactly as much as unverified, and is treated the same way:
|
|
115
|
+
// the drain's verify-by-dereference still stands behind it.
|
|
116
|
+
export function receiptVouchesFor(intake, receipt, actor) {
|
|
117
|
+
if (!receipt?.verified) return false;
|
|
118
|
+
if (!receipt.actor || receipt.actor !== actor) return false;
|
|
119
|
+
// A missing keyId with verified:true cannot come from our own door
|
|
120
|
+
// (makeReceipt fills both from the same key), so refuse rather than waive.
|
|
121
|
+
if (!receipt.keyId || !sameOrigin(receipt.keyId, actor)) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Is this really gone at its origin? true / false / null when the origin
|
|
126
|
+
// could not be asked. Delivered bodies carry no signature, so this is how a
|
|
127
|
+
// Delete is verified — the same verify-by-dereference the rest of intake uses.
|
|
128
|
+
export async function isGone(intake, url) {
|
|
129
|
+
let res;
|
|
130
|
+
try { res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
|
|
131
|
+
catch { return null; }
|
|
132
|
+
if (res.status === 404 || res.status === 410) return true;
|
|
133
|
+
if (res.status < 400) {
|
|
134
|
+
// A Tombstone answers 200 and still means deleted.
|
|
135
|
+
try {
|
|
136
|
+
const { readCapped } = await import('../../shared/safefetch.mjs');
|
|
137
|
+
const read = await readLenient(await readCapped(res));
|
|
138
|
+
return (read.view ?? read.doc)?.type === 'Tombstone';
|
|
139
|
+
} catch { return false; }
|
|
140
|
+
}
|
|
141
|
+
return null; // 401/403/5xx — no answer, not a denial
|
|
142
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { writeJsonAtomic } from '
|
|
15
|
+
import { writeJsonAtomic } from '../device/home.mjs';
|
|
16
16
|
|
|
17
17
|
const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
18
18
|
|