@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
objects,
|
|
11
11
|
} from "../../../../db/index.ts";
|
|
12
12
|
import { isLocal } from "../../../federation-helpers.ts";
|
|
13
|
+
import { activityPubActorIdentityMatchesSql } from "../../../lib/activitypub-actor-identity-sql.ts";
|
|
14
|
+
import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
|
|
15
|
+
import { isBoundedHttpActivityId } from "../../../lib/remote-activity-id.ts";
|
|
16
|
+
import { resolveInboundActivityReference } from "../inbound-activity-reference.ts";
|
|
13
17
|
import type { Activity } from "../inbox-types.ts";
|
|
14
18
|
|
|
15
19
|
// ---------------------------------------------------------------------------
|
|
@@ -83,50 +87,190 @@ const INTERACTION_TABLES: Record<"like" | "announce", InteractionTable> = {
|
|
|
83
87
|
announce: announces,
|
|
84
88
|
};
|
|
85
89
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a follow edge owned by the verified follower identity. Exact DB keys
|
|
92
|
+
* are the steady-state path; the fallback keeps every pre-invariant row
|
|
93
|
+
* reachable without treating a same-origin sibling as the same actor.
|
|
94
|
+
*/
|
|
95
|
+
export async function findFollowByFollowerIdentity(
|
|
88
96
|
db: Database,
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
followerApId: string,
|
|
98
|
+
followingApId: string,
|
|
99
|
+
) {
|
|
100
|
+
const exact = await db
|
|
101
|
+
.select()
|
|
102
|
+
.from(follows)
|
|
103
|
+
.where(
|
|
104
|
+
and(
|
|
105
|
+
eq(follows.followerApId, followerApId),
|
|
106
|
+
eq(follows.followingApId, followingApId),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
.get();
|
|
110
|
+
if (exact) return exact;
|
|
111
|
+
|
|
112
|
+
const retainedFollowers = activityPubActorIdentityMatchesSql(
|
|
113
|
+
sql`
|
|
114
|
+
SELECT ${follows.followerApId}
|
|
115
|
+
FROM ${follows}
|
|
116
|
+
WHERE ${follows.followingApId} = ${followingApId}
|
|
117
|
+
`,
|
|
118
|
+
followerApId,
|
|
119
|
+
);
|
|
120
|
+
return await db
|
|
121
|
+
.select()
|
|
122
|
+
.from(follows)
|
|
123
|
+
.where(
|
|
124
|
+
and(
|
|
125
|
+
eq(follows.followingApId, followingApId),
|
|
126
|
+
sql`${follows.followerApId} IN (${retainedFollowers})`,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
.limit(1)
|
|
130
|
+
.get();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Same compatibility lookup when the verified actor owns the followee side. */
|
|
134
|
+
export async function findFollowByFollowingIdentity(
|
|
135
|
+
db: Database,
|
|
136
|
+
followerApId: string,
|
|
137
|
+
followingApId: string,
|
|
138
|
+
) {
|
|
139
|
+
const exact = await db
|
|
140
|
+
.select()
|
|
141
|
+
.from(follows)
|
|
142
|
+
.where(
|
|
143
|
+
and(
|
|
144
|
+
eq(follows.followerApId, followerApId),
|
|
145
|
+
eq(follows.followingApId, followingApId),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
.get();
|
|
149
|
+
if (exact) return exact;
|
|
150
|
+
|
|
151
|
+
const retainedFollowing = activityPubActorIdentityMatchesSql(
|
|
152
|
+
sql`
|
|
153
|
+
SELECT ${follows.followingApId}
|
|
154
|
+
FROM ${follows}
|
|
155
|
+
WHERE ${follows.followerApId} = ${followerApId}
|
|
156
|
+
`,
|
|
157
|
+
followingApId,
|
|
158
|
+
);
|
|
159
|
+
return await db
|
|
160
|
+
.select()
|
|
161
|
+
.from(follows)
|
|
162
|
+
.where(
|
|
163
|
+
and(
|
|
164
|
+
eq(follows.followerApId, followerApId),
|
|
165
|
+
sql`${follows.followingApId} IN (${retainedFollowing})`,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
.limit(1)
|
|
169
|
+
.get();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type FollowLookupRow = {
|
|
91
173
|
followerApId: string;
|
|
92
174
|
followingApId: string;
|
|
93
175
|
status: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
async function findActorOwnedFollowByActivityId(
|
|
179
|
+
db: Database,
|
|
180
|
+
activityApIdValue: string,
|
|
181
|
+
actorApId: string,
|
|
182
|
+
): Promise<FollowLookupRow | null> {
|
|
183
|
+
const columns = {
|
|
184
|
+
followerApId: follows.followerApId,
|
|
185
|
+
followingApId: follows.followingApId,
|
|
186
|
+
status: follows.status,
|
|
187
|
+
};
|
|
188
|
+
const exact = await db
|
|
189
|
+
.select(columns)
|
|
101
190
|
.from(follows)
|
|
102
|
-
.where(
|
|
191
|
+
.where(
|
|
192
|
+
and(
|
|
193
|
+
eq(follows.activityApId, activityApIdValue),
|
|
194
|
+
eq(follows.followerApId, actorApId),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
103
197
|
.get();
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
198
|
+
if (exact) return exact;
|
|
199
|
+
|
|
200
|
+
// Current ingress stores the verified key-owner spelling, so this only
|
|
201
|
+
// serves rows created before that invariant. Match the complete retained set:
|
|
202
|
+
// a public activity id may be repeated and must not make an older legitimate
|
|
203
|
+
// edge unreachable or authorize a same-origin sibling.
|
|
204
|
+
const retainedFollowers = activityPubActorIdentityMatchesSql(
|
|
205
|
+
sql`
|
|
206
|
+
SELECT ${follows.followerApId}
|
|
207
|
+
FROM ${follows}
|
|
208
|
+
WHERE ${follows.activityApId} = ${activityApIdValue}
|
|
209
|
+
`,
|
|
210
|
+
actorApId,
|
|
211
|
+
);
|
|
212
|
+
return (
|
|
213
|
+
(await db
|
|
214
|
+
.select(columns)
|
|
215
|
+
.from(follows)
|
|
112
216
|
.where(
|
|
113
217
|
and(
|
|
114
|
-
eq(
|
|
115
|
-
sql
|
|
218
|
+
eq(follows.activityApId, activityApIdValue),
|
|
219
|
+
sql`${follows.followerApId} IN (${retainedFollowers})`,
|
|
116
220
|
),
|
|
117
221
|
)
|
|
118
|
-
.limit(
|
|
119
|
-
|
|
120
|
-
|
|
222
|
+
.limit(1)
|
|
223
|
+
.get()) ?? null
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Find a follow by its retained activity IRI. When `source` is present, a peer
|
|
229
|
+
* wire ID may also resolve through that verified actor's inbound ledger row.
|
|
230
|
+
*/
|
|
231
|
+
export async function findFollowByActivityId(
|
|
232
|
+
db: Database,
|
|
233
|
+
activityApIdValue: string,
|
|
234
|
+
source?: { actorApId: string; localBaseUrl: string },
|
|
235
|
+
): Promise<{
|
|
236
|
+
followerApId: string;
|
|
237
|
+
followingApId: string;
|
|
238
|
+
status: string;
|
|
239
|
+
} | null> {
|
|
240
|
+
if (!isBoundedHttpActivityId(activityApIdValue)) return null;
|
|
241
|
+
|
|
242
|
+
let row = source
|
|
243
|
+
? await findActorOwnedFollowByActivityId(
|
|
244
|
+
db,
|
|
245
|
+
activityApIdValue,
|
|
246
|
+
source.actorApId,
|
|
247
|
+
)
|
|
248
|
+
: await db
|
|
121
249
|
.select({
|
|
122
250
|
followerApId: follows.followerApId,
|
|
123
251
|
followingApId: follows.followingApId,
|
|
124
252
|
status: follows.status,
|
|
125
253
|
})
|
|
126
254
|
.from(follows)
|
|
127
|
-
.where(eq(follows.activityApId,
|
|
255
|
+
.where(eq(follows.activityApId, activityApIdValue))
|
|
128
256
|
.get();
|
|
129
|
-
|
|
257
|
+
if (!row && source) {
|
|
258
|
+
// Inbound envelopes use an origin-bound internal activities.apId. Undo
|
|
259
|
+
// carries the peer's protocol id, so resolve that bounded raw-json id back
|
|
260
|
+
// to the internal row before looking up the follow edge. The direct lookup
|
|
261
|
+
// above preserves compatibility with legacy rows.
|
|
262
|
+
const internalActivityApId = await resolveInboundActivityReference(
|
|
263
|
+
db,
|
|
264
|
+
activityApIdValue,
|
|
265
|
+
source.actorApId,
|
|
266
|
+
source.localBaseUrl,
|
|
267
|
+
);
|
|
268
|
+
if (internalActivityApId) {
|
|
269
|
+
row = await findActorOwnedFollowByActivityId(
|
|
270
|
+
db,
|
|
271
|
+
internalActivityApId,
|
|
272
|
+
source.actorApId,
|
|
273
|
+
);
|
|
130
274
|
}
|
|
131
275
|
}
|
|
132
276
|
return row ?? null;
|
|
@@ -224,6 +368,15 @@ export async function undoInteraction(
|
|
|
224
368
|
const cf = countField ?? COUNT_FIELDS[kind];
|
|
225
369
|
|
|
226
370
|
if (directObjectId) {
|
|
371
|
+
const retainedActors = activityPubActorIdentityMatchesSql(
|
|
372
|
+
sql`
|
|
373
|
+
SELECT ${table.actorApId}
|
|
374
|
+
FROM ${table}
|
|
375
|
+
WHERE ${table.objectApId} = ${directObjectId}
|
|
376
|
+
`,
|
|
377
|
+
actor,
|
|
378
|
+
);
|
|
379
|
+
|
|
227
380
|
// Delete the edge and recompute the counter from the remaining edge rows
|
|
228
381
|
// atomically. A duplicate Undo (or an Undo of an interaction we never
|
|
229
382
|
// recorded) deletes 0 rows and the recompute is a no-op against the
|
|
@@ -232,7 +385,10 @@ export async function undoInteraction(
|
|
|
232
385
|
db
|
|
233
386
|
.delete(table)
|
|
234
387
|
.where(
|
|
235
|
-
and(
|
|
388
|
+
and(
|
|
389
|
+
eq(table.objectApId, directObjectId),
|
|
390
|
+
sql`${table.actorApId} IN (${retainedActors})`,
|
|
391
|
+
),
|
|
236
392
|
),
|
|
237
393
|
db
|
|
238
394
|
.update(objects)
|
|
@@ -248,27 +404,59 @@ export async function undoInteraction(
|
|
|
248
404
|
|
|
249
405
|
// Resolve the edge by its activity ID, then commit the delete + recompute in
|
|
250
406
|
// one batch keyed on the resolved objectApId.
|
|
251
|
-
|
|
407
|
+
let record = await db
|
|
252
408
|
.select({
|
|
253
409
|
actorApId: table.actorApId,
|
|
254
410
|
objectApId: table.objectApId,
|
|
255
411
|
})
|
|
256
412
|
.from(table)
|
|
257
|
-
.where(eq(table.activityApId, activityId))
|
|
413
|
+
.where(and(eq(table.activityApId, activityId), eq(table.actorApId, actor)))
|
|
258
414
|
.get();
|
|
415
|
+
if (!record) {
|
|
416
|
+
const retainedActors = activityPubActorIdentityMatchesSql(
|
|
417
|
+
sql`
|
|
418
|
+
SELECT ${table.actorApId}
|
|
419
|
+
FROM ${table}
|
|
420
|
+
WHERE ${table.activityApId} = ${activityId}
|
|
421
|
+
`,
|
|
422
|
+
actor,
|
|
423
|
+
);
|
|
424
|
+
record = await db
|
|
425
|
+
.select({
|
|
426
|
+
actorApId: table.actorApId,
|
|
427
|
+
objectApId: table.objectApId,
|
|
428
|
+
})
|
|
429
|
+
.from(table)
|
|
430
|
+
.where(
|
|
431
|
+
and(
|
|
432
|
+
eq(table.activityApId, activityId),
|
|
433
|
+
sql`${table.actorApId} IN (${retainedActors})`,
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
.limit(1)
|
|
437
|
+
.get();
|
|
438
|
+
}
|
|
259
439
|
if (record) {
|
|
260
440
|
// Bind the undo to the VERIFIED signer. The activity id is public, so a
|
|
261
441
|
// resolved edge whose owner != the signing actor is a cross-actor forgery
|
|
262
442
|
// (a remote attacker undoing someone else's like/announce by id). The
|
|
263
443
|
// directObjectId branch above already keys its delete on `actor`; mirror it.
|
|
264
|
-
if (record.actorApId
|
|
444
|
+
if (!isSameActivityPubActor(record.actorApId, actor)) return false;
|
|
445
|
+
const retainedActors = activityPubActorIdentityMatchesSql(
|
|
446
|
+
sql`
|
|
447
|
+
SELECT ${table.actorApId}
|
|
448
|
+
FROM ${table}
|
|
449
|
+
WHERE ${table.objectApId} = ${record.objectApId}
|
|
450
|
+
`,
|
|
451
|
+
actor,
|
|
452
|
+
);
|
|
265
453
|
await runBatch(db, [
|
|
266
454
|
db
|
|
267
455
|
.delete(table)
|
|
268
456
|
.where(
|
|
269
457
|
and(
|
|
270
|
-
eq(table.actorApId, record.actorApId),
|
|
271
458
|
eq(table.objectApId, record.objectApId),
|
|
459
|
+
sql`${table.actorApId} IN (${retainedActors})`,
|
|
272
460
|
),
|
|
273
461
|
),
|
|
274
462
|
db
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { activityApId } from "../../federation-helpers.ts";
|
|
2
|
+
import { normalizeActivityPubActorId } from "../../lib/activitypub-actor-identity.ts";
|
|
3
|
+
import { sha256Hex } from "../../lib/delivery/transformers.ts";
|
|
4
|
+
|
|
5
|
+
/** Build the fixed-size, actor-scoped local ledger IRI for an inbound source. */
|
|
6
|
+
export async function internalInboundActivityId(
|
|
7
|
+
baseUrl: string,
|
|
8
|
+
actor: string,
|
|
9
|
+
source: string,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const actorIdentity = normalizeActivityPubActorId(actor) ?? actor;
|
|
12
|
+
return activityApId(
|
|
13
|
+
baseUrl,
|
|
14
|
+
`inbound-${await sha256Hex(`${actorIdentity}\0${source}`)}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { Database } from "../../../db/index.ts";
|
|
4
|
+
import { activities } from "../../../db/index.ts";
|
|
5
|
+
import { isSameActivityPubActor } from "../../lib/activitypub-actor-identity.ts";
|
|
6
|
+
import { activityPubActorIdentityMatchesSql } from "../../lib/activitypub-actor-identity-sql.ts";
|
|
7
|
+
import {
|
|
8
|
+
isBoundedHttpActivityId,
|
|
9
|
+
isTrustedRemoteActivityId,
|
|
10
|
+
} from "../../lib/remote-activity-id.ts";
|
|
11
|
+
import { internalInboundActivityId } from "./inbound-activity-identity.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve an Activity reference controlled by a verified remote actor to the
|
|
15
|
+
* canonical local ledger IRI.
|
|
16
|
+
*
|
|
17
|
+
* The direct path covers the local `inbound-<hash>` IRI we may have exposed as
|
|
18
|
+
* a fallback. The raw-envelope path covers a normal peer wire ID, but only
|
|
19
|
+
* within the signing actor and only when that actor owns the ID's exact origin.
|
|
20
|
+
* Actor scoping is essential because Activity IDs are origin-owned: sibling
|
|
21
|
+
* actors on one server may otherwise collide and make a legitimate Undo bind
|
|
22
|
+
* to whichever row SQLite happens to return first.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveInboundActivityReference(
|
|
25
|
+
db: Database,
|
|
26
|
+
reference: string,
|
|
27
|
+
actorApId: string,
|
|
28
|
+
localBaseUrl: string,
|
|
29
|
+
): Promise<string | null> {
|
|
30
|
+
if (!isBoundedHttpActivityId(reference)) return null;
|
|
31
|
+
|
|
32
|
+
const direct = await db
|
|
33
|
+
.select({ apId: activities.apId, actorApId: activities.actorApId })
|
|
34
|
+
.from(activities)
|
|
35
|
+
.where(eq(activities.apId, reference))
|
|
36
|
+
.get();
|
|
37
|
+
if (direct && isSameActivityPubActor(direct.actorApId, actorApId)) {
|
|
38
|
+
return direct.apId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isTrustedRemoteActivityId(reference, actorApId, localBaseUrl)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Current inbound rows use this deterministic actor+source key. Resolve it
|
|
46
|
+
// first so a sibling actor reusing the same public wire id can neither poison
|
|
47
|
+
// the lookup nor force a scan of retained envelopes.
|
|
48
|
+
const expectedInternalId = await internalInboundActivityId(
|
|
49
|
+
localBaseUrl,
|
|
50
|
+
actorApId,
|
|
51
|
+
reference,
|
|
52
|
+
);
|
|
53
|
+
const retainedCurrent = await db
|
|
54
|
+
.select({ apId: activities.apId, actorApId: activities.actorApId })
|
|
55
|
+
.from(activities)
|
|
56
|
+
.where(
|
|
57
|
+
and(
|
|
58
|
+
eq(activities.apId, expectedInternalId),
|
|
59
|
+
eq(activities.direction, "inbound"),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
.get();
|
|
63
|
+
if (
|
|
64
|
+
retainedCurrent &&
|
|
65
|
+
isSameActivityPubActor(retainedCurrent.actorApId, actorApId)
|
|
66
|
+
) {
|
|
67
|
+
return retainedCurrent.apId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Preserve legacy rows whose primary key predates the deterministic key.
|
|
71
|
+
// Keep the common exact-spelling lookup indexed by actor first.
|
|
72
|
+
const retainedExact = await db
|
|
73
|
+
.select({ apId: activities.apId })
|
|
74
|
+
.from(activities)
|
|
75
|
+
.where(
|
|
76
|
+
and(
|
|
77
|
+
eq(activities.direction, "inbound"),
|
|
78
|
+
eq(activities.actorApId, actorApId),
|
|
79
|
+
// A corrupted/legacy raw_json row must not make the whole lookup throw.
|
|
80
|
+
sql`CASE WHEN json_valid(${activities.rawJson}) THEN json_extract(${activities.rawJson}, '$.id') ELSE NULL END = ${reference}`,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
.get();
|
|
84
|
+
if (retainedExact) return retainedExact.apId;
|
|
85
|
+
|
|
86
|
+
// Older rows may retain the same key owner under an accepted cosmetic actor
|
|
87
|
+
// spelling. Search the complete candidate set in SQL: a public wire id can
|
|
88
|
+
// collide across arbitrarily many same-origin sibling actors, so a fixed JS
|
|
89
|
+
// prefix made the legitimate signer-dependent row unreachable. The shared
|
|
90
|
+
// identity matcher keeps path case and sibling actor paths distinct while
|
|
91
|
+
// using a constant parameter count.
|
|
92
|
+
const retainedLegacyActors = activityPubActorIdentityMatchesSql(
|
|
93
|
+
sql`
|
|
94
|
+
SELECT ${activities.actorApId}
|
|
95
|
+
FROM ${activities}
|
|
96
|
+
WHERE ${activities.direction} = 'inbound'
|
|
97
|
+
AND CASE WHEN json_valid(${activities.rawJson})
|
|
98
|
+
THEN json_extract(${activities.rawJson}, '$.id')
|
|
99
|
+
ELSE NULL END = ${reference}
|
|
100
|
+
`,
|
|
101
|
+
actorApId,
|
|
102
|
+
);
|
|
103
|
+
const retainedLegacy = await db
|
|
104
|
+
.select({ apId: activities.apId, actorApId: activities.actorApId })
|
|
105
|
+
.from(activities)
|
|
106
|
+
.where(
|
|
107
|
+
and(
|
|
108
|
+
eq(activities.direction, "inbound"),
|
|
109
|
+
sql`CASE WHEN json_valid(${activities.rawJson}) THEN json_extract(${activities.rawJson}, '$.id') ELSE NULL END = ${reference}`,
|
|
110
|
+
sql`${activities.actorApId} IN (${retainedLegacyActors})`,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
.limit(1)
|
|
114
|
+
.get();
|
|
115
|
+
return retainedLegacy?.apId ?? null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Activity, ActivityObject } from "./inbox-types.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_INBOUND_ADDRESS_ENTRIES = 64;
|
|
4
|
+
export const MAX_INBOUND_ADDRESS_LENGTH = 2048;
|
|
5
|
+
|
|
6
|
+
const ADDRESS_FIELDS = ["to", "cc", "bto", "bcc", "audience"] as const;
|
|
7
|
+
type AddressField = (typeof ADDRESS_FIELDS)[number];
|
|
8
|
+
type AddressSource = Pick<
|
|
9
|
+
Activity | ActivityObject,
|
|
10
|
+
"to" | "cc" | "bto" | "bcc" | "audience"
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export type InboundAddressingFailureReason = "too_many" | "too_long";
|
|
14
|
+
|
|
15
|
+
export type InboundAddressingResult =
|
|
16
|
+
| { ok: true; addresses: string[] }
|
|
17
|
+
| {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: InboundAddressingFailureReason;
|
|
20
|
+
addresses: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// The ActivityStreams public-collection magic value, including legacy short
|
|
24
|
+
// forms some implementations still emit.
|
|
25
|
+
const PUBLIC_COLLECTION = new Set([
|
|
26
|
+
"https://www.w3.org/ns/activitystreams#Public",
|
|
27
|
+
"as:Public",
|
|
28
|
+
"Public",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function addressesPublic(addresses: readonly string[]): boolean {
|
|
32
|
+
return addresses.some((address) => PUBLIC_COLLECTION.has(address));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate one complete inbound addressing projection without truncation.
|
|
37
|
+
*
|
|
38
|
+
* A field/value pair is counted once across the envelope and embedded object,
|
|
39
|
+
* so peers that repeat the same projection in both places remain compatible.
|
|
40
|
+
* Field placement remains significant (`Public` in `to` and `cc` are distinct
|
|
41
|
+
* reach declarations), while duplicate entries inside one field do not create
|
|
42
|
+
* storage or lookup amplification. The returned flat list is de-duplicated for
|
|
43
|
+
* shared-inbox routing; callers retain their field-specific arrays separately.
|
|
44
|
+
*/
|
|
45
|
+
export function collectBoundedInboundAddresses(
|
|
46
|
+
sources: readonly AddressSource[],
|
|
47
|
+
): InboundAddressingResult {
|
|
48
|
+
const seenByField = new Map<AddressField, Set<string>>();
|
|
49
|
+
const addresses = new Set<string>();
|
|
50
|
+
let entryCount = 0;
|
|
51
|
+
|
|
52
|
+
for (const source of sources) {
|
|
53
|
+
for (const field of ADDRESS_FIELDS) {
|
|
54
|
+
const raw = source[field] as unknown;
|
|
55
|
+
const candidates =
|
|
56
|
+
typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
|
|
57
|
+
let seen = seenByField.get(field);
|
|
58
|
+
if (!seen) {
|
|
59
|
+
seen = new Set<string>();
|
|
60
|
+
seenByField.set(field, seen);
|
|
61
|
+
}
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
64
|
+
if (candidate.length > MAX_INBOUND_ADDRESS_LENGTH) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "too_long",
|
|
68
|
+
addresses: [...addresses],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (seen.has(candidate)) continue;
|
|
72
|
+
if (entryCount >= MAX_INBOUND_ADDRESS_ENTRIES) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "too_many",
|
|
76
|
+
addresses: [...addresses],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
seen.add(candidate);
|
|
80
|
+
addresses.add(candidate);
|
|
81
|
+
entryCount += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, addresses: [...addresses] };
|
|
87
|
+
}
|
|
@@ -29,8 +29,10 @@ import { isLocal } from "../../federation-helpers.ts";
|
|
|
29
29
|
import type { Activity } from "./inbox-types.ts";
|
|
30
30
|
import {
|
|
31
31
|
addressesPublic,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
collectBoundedInboundAddresses,
|
|
33
|
+
type InboundAddressingFailureReason,
|
|
34
|
+
type InboundAddressingResult,
|
|
35
|
+
} from "./inbound-addressing.ts";
|
|
34
36
|
|
|
35
37
|
export type ActorRow = typeof actors.$inferSelect;
|
|
36
38
|
|
|
@@ -114,21 +116,11 @@ export function isHandledActivityType(
|
|
|
114
116
|
* `bto`/`bcc` are included: a peer that strips them before delivery simply
|
|
115
117
|
* contributes nothing, and one that does not must still be honoured.
|
|
116
118
|
*/
|
|
117
|
-
export function collectAddresses(activity: Activity):
|
|
119
|
+
export function collectAddresses(activity: Activity): InboundAddressingResult {
|
|
118
120
|
const object = typeof activity.object === "string" ? null : activity.object;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
...(activity.bto ?? []),
|
|
123
|
-
...(activity.bcc ?? []),
|
|
124
|
-
...(activity.audience ?? []),
|
|
125
|
-
...(object?.to ?? []),
|
|
126
|
-
...(object?.cc ?? []),
|
|
127
|
-
];
|
|
128
|
-
// Same bound the persisted addressing arrays use (policy defined once, in
|
|
129
|
-
// inbox-content-handlers): a remote must not be able to make one delivery
|
|
130
|
-
// cost an unbounded number of actor lookups.
|
|
131
|
-
return [...new Set(all)].slice(0, MAX_ADDRESS_ENTRIES);
|
|
121
|
+
return collectBoundedInboundAddresses(
|
|
122
|
+
object ? [activity, object] : [activity],
|
|
123
|
+
);
|
|
132
124
|
}
|
|
133
125
|
|
|
134
126
|
/**
|
|
@@ -146,6 +138,8 @@ export type AddressingResolution = {
|
|
|
146
138
|
readonly recipients: readonly ActorRow[];
|
|
147
139
|
/** Addressing IRIs read off the activity, for logging. */
|
|
148
140
|
readonly addresses: readonly string[];
|
|
141
|
+
/** Invalid projections are undeliverable and must never be truncated. */
|
|
142
|
+
readonly invalidReason?: InboundAddressingFailureReason;
|
|
149
143
|
};
|
|
150
144
|
|
|
151
145
|
/**
|
|
@@ -160,8 +154,8 @@ async function resolveAddressedLocalActors(
|
|
|
160
154
|
(a) => isLocal(a, baseUrl) && !addressesPublic([a]),
|
|
161
155
|
);
|
|
162
156
|
if (localIris.length === 0) return [];
|
|
163
|
-
// Bounded by
|
|
164
|
-
// D1 parameter rule holds if that bound is ever raised.
|
|
157
|
+
// Bounded by the shared 64-entry contract, but routed through inChunks so
|
|
158
|
+
// the D1 parameter rule holds if that bound is ever raised.
|
|
165
159
|
return await inChunks(localIris, (chunk) =>
|
|
166
160
|
db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
|
|
167
161
|
);
|
|
@@ -214,7 +208,16 @@ export async function resolveAddressedRecipients(
|
|
|
214
208
|
baseUrl: string,
|
|
215
209
|
followerFanoutLimit: number,
|
|
216
210
|
): Promise<AddressingResolution> {
|
|
217
|
-
const
|
|
211
|
+
const collected = collectAddresses(activity);
|
|
212
|
+
if (!collected.ok) {
|
|
213
|
+
return {
|
|
214
|
+
cls: "addressed",
|
|
215
|
+
recipients: [],
|
|
216
|
+
addresses: collected.addresses,
|
|
217
|
+
invalidReason: collected.reason,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const addresses = collected.addresses;
|
|
218
221
|
const addressed = await resolveAddressedLocalActors(db, addresses, baseUrl);
|
|
219
222
|
|
|
220
223
|
const wantsAudience =
|
|
@@ -7,9 +7,13 @@ export type ActivityObject = {
|
|
|
7
7
|
id?: string;
|
|
8
8
|
type?: string | string[];
|
|
9
9
|
object?: string;
|
|
10
|
-
|
|
10
|
+
/** Raw ingress value; Note handlers must validate before SQL or storage. */
|
|
11
|
+
inReplyTo?: unknown;
|
|
11
12
|
to?: string[];
|
|
12
13
|
cc?: string[];
|
|
14
|
+
bto?: string[];
|
|
15
|
+
bcc?: string[];
|
|
16
|
+
audience?: string[];
|
|
13
17
|
conversation?: string;
|
|
14
18
|
content?: string;
|
|
15
19
|
summary?: string | null;
|
|
@@ -27,7 +31,15 @@ export type Activity = {
|
|
|
27
31
|
type?: string;
|
|
28
32
|
actor?: string;
|
|
29
33
|
object?: string | ActivityObject;
|
|
34
|
+
/**
|
|
35
|
+
* Bounded object references extracted from scalar, embedded, or array-valued
|
|
36
|
+
* AS2 `object`. Standard Flag activities use an array; keeping the normalized
|
|
37
|
+
* ids separately lets ordinary handlers retain their scalar/object model.
|
|
38
|
+
*/
|
|
39
|
+
objectIds?: string[];
|
|
30
40
|
target?: string | ActivityObject;
|
|
41
|
+
/** Envelope-level content, used by Flag as the moderation reason. */
|
|
42
|
+
content?: string;
|
|
31
43
|
room?: string;
|
|
32
44
|
// Envelope addressing, preserved by parseActivity. Shared-inbox routing is
|
|
33
45
|
// derived from these (inbox-addressing.ts); before they were parsed the
|
|
@@ -76,7 +88,7 @@ export function getActivityObject(activity: Activity): ActivityObject | null {
|
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export function getActivityObjectId(activity: Activity): string | null {
|
|
79
|
-
if (!activity.object) return null;
|
|
91
|
+
if (!activity.object) return activity.objectIds?.[0] ?? null;
|
|
80
92
|
if (typeof activity.object === "string") return activity.object;
|
|
81
93
|
return activity.object.id || null;
|
|
82
94
|
}
|