@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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { and, eq,
|
|
1
|
+
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
|
2
2
|
import type { BatchItem } from "drizzle-orm/batch";
|
|
3
3
|
import {
|
|
4
4
|
actors,
|
|
5
5
|
announces,
|
|
6
|
+
communities,
|
|
6
7
|
follows,
|
|
7
8
|
likes,
|
|
8
9
|
objects,
|
|
@@ -19,15 +20,25 @@ import {
|
|
|
19
20
|
type ActivityContext,
|
|
20
21
|
getActivityObjectId,
|
|
21
22
|
} from "../inbox-types.ts";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
findFollowByFollowingIdentity,
|
|
25
|
+
notifyLocalObjectOwner,
|
|
26
|
+
} from "./inbox-shared-helpers.ts";
|
|
23
27
|
import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
|
|
24
28
|
import { isLocal } from "../../../lib/ap-ids.ts";
|
|
25
29
|
import { notDeleted } from "../../../../db/index.ts";
|
|
26
30
|
import {
|
|
27
|
-
|
|
31
|
+
actorSuppressesInteractionFrom,
|
|
28
32
|
canViewerReadObjectFull,
|
|
29
33
|
} from "../../../lib/post-visibility.ts";
|
|
30
34
|
import { logger } from "../../../lib/logger.ts";
|
|
35
|
+
import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
|
|
36
|
+
import { MAX_ACTIVITY_OBJECT_IDS } from "../../../lib/activitypub-validators.ts";
|
|
37
|
+
import {
|
|
38
|
+
acceptRetainedFollowDirection,
|
|
39
|
+
severFollowDirection,
|
|
40
|
+
severFollowPair,
|
|
41
|
+
} from "../../../lib/follow-edge-mutations.ts";
|
|
31
42
|
|
|
32
43
|
type ActorRow = typeof actors.$inferSelect;
|
|
33
44
|
|
|
@@ -91,15 +102,16 @@ async function handleInteraction(
|
|
|
91
102
|
const objectId = getActivityObjectId(activity);
|
|
92
103
|
if (!objectId) return;
|
|
93
104
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
105
|
+
// Apply the canonical read gate to EVERY object retained by this instance,
|
|
106
|
+
// including a remote-authored object delivered to a local recipient. Limiting
|
|
107
|
+
// this check to locally-authored targets let an unrelated signer Like/Announce
|
|
108
|
+
// a cached remote DM or followers-only Note, mutating its edge/counter state
|
|
109
|
+
// despite having no read access. Personal blocks/mutes remain local-owner
|
|
110
|
+
// state, so that extra write-suppression guard only applies when the retained
|
|
111
|
+
// object's author is local.
|
|
101
112
|
const target = await db
|
|
102
113
|
.select({
|
|
114
|
+
apId: objects.apId,
|
|
103
115
|
attributedTo: objects.attributedTo,
|
|
104
116
|
visibility: objects.visibility,
|
|
105
117
|
toJson: objects.toJson,
|
|
@@ -110,12 +122,26 @@ async function handleInteraction(
|
|
|
110
122
|
endTime: objects.endTime,
|
|
111
123
|
})
|
|
112
124
|
.from(objects)
|
|
113
|
-
.where(eq(objects.apId, objectId))
|
|
125
|
+
.where(and(eq(objects.apId, objectId), notDeleted(objects)))
|
|
114
126
|
.get();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
// Never retain a relationship to an object this instance does not currently
|
|
128
|
+
// retain as live. With no D1 FK (remote identities/relationships are
|
|
129
|
+
// intentionally app-managed), the old path inserted one permanent edge for
|
|
130
|
+
// every attacker-chosen object id even though the counter update matched no
|
|
131
|
+
// row. A signer could therefore grow likes/announces without bound, and a
|
|
132
|
+
// soft-deleted object could accumulate fresh interactions. An unknown
|
|
133
|
+
// Announce from a followed actor still gets its one bounded, validated fetch
|
|
134
|
+
// in handleAnnounce BEFORE reaching this guard; only a successfully persisted
|
|
135
|
+
// public/unlisted Note proceeds.
|
|
136
|
+
if (!target) return;
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
isLocal(target.attributedTo, baseUrl) &&
|
|
140
|
+
(await actorSuppressesInteractionFrom(db, target.attributedTo, actor))
|
|
141
|
+
) {
|
|
142
|
+
return;
|
|
118
143
|
}
|
|
144
|
+
if (!(await canViewerReadObjectFull(db, target, actor))) return;
|
|
119
145
|
|
|
120
146
|
const { table, countField, activityType } = INTERACTION_CONFIG[kind];
|
|
121
147
|
const activityId = activity.id || activityApId(baseUrl, generateId());
|
|
@@ -232,10 +258,9 @@ export async function handleAnnounce(
|
|
|
232
258
|
// still surfaces in feeds (previously the Announce only left a dangling
|
|
233
259
|
// announce edge + a counter no-op). Strictly gated: the target must be
|
|
234
260
|
// remote and unknown, and the booster must have at least one local follower
|
|
235
|
-
// (no open-relay amplification). On any fetch/validation failure
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// so a later Create of the same object retroactively completes the boost.
|
|
261
|
+
// (no open-relay amplification). On any fetch/validation failure,
|
|
262
|
+
// handleInteraction drops the Announce because there is no live retained
|
|
263
|
+
// target; it must not create a dangling edge for an attacker-chosen id.
|
|
239
264
|
const db = c.get("db");
|
|
240
265
|
const objectId = getActivityObjectId(activity);
|
|
241
266
|
if (objectId && !isLocal(objectId, baseUrl)) {
|
|
@@ -277,6 +302,18 @@ export async function handleAdd(
|
|
|
277
302
|
|
|
278
303
|
const db = c.get("db");
|
|
279
304
|
const now = new Date().toISOString();
|
|
305
|
+
const retainedEdge = await findFollowByFollowingIdentity(
|
|
306
|
+
db,
|
|
307
|
+
recipient.apId,
|
|
308
|
+
followingApId,
|
|
309
|
+
);
|
|
310
|
+
if (
|
|
311
|
+
!retainedEdge ||
|
|
312
|
+
(retainedEdge.status !== "pending" && retainedEdge.status !== "accepted")
|
|
313
|
+
) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const retainedFollowingApId = retainedEdge.followingApId;
|
|
280
317
|
|
|
281
318
|
// SECURITY (consent — federated follow-graph forgery): an `Add <local user>
|
|
282
319
|
// to <remote>/followers` is the remote CONFIRMING the local user's OWN Follow
|
|
@@ -289,32 +326,16 @@ export async function handleAdd(
|
|
|
289
326
|
// inflating the victim's followingCount and routing the sender's posts into
|
|
290
327
|
// the victim's home feed — all without the victim ever following anyone.
|
|
291
328
|
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.where(and(eq(actors.apId, recipient.apId), pendingEdgeExists)),
|
|
303
|
-
db
|
|
304
|
-
.update(actors)
|
|
305
|
-
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
306
|
-
.where(and(eq(actors.apId, followingApId), pendingEdgeExists)),
|
|
307
|
-
db
|
|
308
|
-
.update(follows)
|
|
309
|
-
.set({ status: "accepted", acceptedAt: now })
|
|
310
|
-
.where(
|
|
311
|
-
and(
|
|
312
|
-
eq(follows.followerApId, recipient.apId),
|
|
313
|
-
eq(follows.followingApId, followingApId),
|
|
314
|
-
eq(follows.status, "pending"),
|
|
315
|
-
),
|
|
316
|
-
),
|
|
317
|
-
]);
|
|
329
|
+
// Like Accept, collapse cosmetic duplicates and derive the counter delta from
|
|
330
|
+
// the retained accepted-row set. This preserves the consent requirement (an
|
|
331
|
+
// existing pending/accepted edge is mandatory) while making retries and
|
|
332
|
+
// legacy duplicate rows converge to one logical relation.
|
|
333
|
+
await acceptRetainedFollowDirection(
|
|
334
|
+
db,
|
|
335
|
+
recipient.apId,
|
|
336
|
+
retainedFollowingApId,
|
|
337
|
+
now,
|
|
338
|
+
);
|
|
318
339
|
}
|
|
319
340
|
|
|
320
341
|
// ---------------------------------------------------------------------------
|
|
@@ -331,49 +352,19 @@ export async function handleRemove(
|
|
|
331
352
|
if (!followingApId) return;
|
|
332
353
|
|
|
333
354
|
const db = c.get("db");
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
await runBatch(db, [
|
|
348
|
-
db
|
|
349
|
-
.update(actors)
|
|
350
|
-
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
351
|
-
.where(
|
|
352
|
-
and(
|
|
353
|
-
eq(actors.apId, recipient.apId),
|
|
354
|
-
gt(actors.followingCount, 0),
|
|
355
|
-
acceptedEdgeExists,
|
|
356
|
-
),
|
|
357
|
-
),
|
|
358
|
-
db
|
|
359
|
-
.update(actors)
|
|
360
|
-
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
361
|
-
.where(
|
|
362
|
-
and(
|
|
363
|
-
eq(actors.apId, followingApId),
|
|
364
|
-
gt(actors.followerCount, 0),
|
|
365
|
-
acceptedEdgeExists,
|
|
366
|
-
),
|
|
367
|
-
),
|
|
368
|
-
db
|
|
369
|
-
.delete(follows)
|
|
370
|
-
.where(
|
|
371
|
-
and(
|
|
372
|
-
eq(follows.followerApId, recipient.apId),
|
|
373
|
-
eq(follows.followingApId, followingApId),
|
|
374
|
-
),
|
|
375
|
-
),
|
|
376
|
-
]);
|
|
355
|
+
const retainedEdge = await findFollowByFollowingIdentity(
|
|
356
|
+
db,
|
|
357
|
+
recipient.apId,
|
|
358
|
+
followingApId,
|
|
359
|
+
);
|
|
360
|
+
if (!retainedEdge) return;
|
|
361
|
+
const retainedFollowingApId = retainedEdge.followingApId;
|
|
362
|
+
|
|
363
|
+
// Remove every retained spelling of this logical edge. The shared mutation
|
|
364
|
+
// co-commits deletion with count reconciliation and remains bounded to the
|
|
365
|
+
// exact local follower, so a cosmetic duplicate cannot keep feed-delivery
|
|
366
|
+
// authority after the remote collection removes the member.
|
|
367
|
+
await severFollowDirection(db, recipient.apId, retainedFollowingApId);
|
|
377
368
|
}
|
|
378
369
|
|
|
379
370
|
// ---------------------------------------------------------------------------
|
|
@@ -393,61 +384,9 @@ export async function handleBlock(
|
|
|
393
384
|
// Only act when the recipient is being blocked.
|
|
394
385
|
if (blockedId !== recipient.apId) return;
|
|
395
386
|
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
// decrements cannot leave a counter permanently over-counted (the peer's
|
|
400
|
-
// retry would otherwise match 0 rows and skip the decrement).
|
|
401
|
-
await severFollowEdge(db, recipient.apId, actor); // recipient follows actor
|
|
402
|
-
await severFollowEdge(db, actor, recipient.apId); // actor follows recipient
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Atomically delete a (followerApId -> followingApId) follow edge and reconcile
|
|
407
|
-
* both denormalized counters in a single batch.
|
|
408
|
-
*
|
|
409
|
-
* #COUNTER-SYM: the decrements run BEFORE the delete, each guarded by a
|
|
410
|
-
* correlated `EXISTS(... status='accepted')` subquery (pending edges were never
|
|
411
|
-
* counted) plus a `count > 0` underflow guard, so a pending edge, a duplicate
|
|
412
|
-
* Block, a never-existing edge, or a crash-then-retry is a clean no-op rather
|
|
413
|
-
* than permanently over-counting.
|
|
414
|
-
*/
|
|
415
|
-
export async function severFollowEdge(
|
|
416
|
-
db: Database,
|
|
417
|
-
followerApId: string,
|
|
418
|
-
followingApId: string,
|
|
419
|
-
): Promise<void> {
|
|
420
|
-
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
|
|
421
|
-
await runBatch(db, [
|
|
422
|
-
db
|
|
423
|
-
.update(actors)
|
|
424
|
-
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
425
|
-
.where(
|
|
426
|
-
and(
|
|
427
|
-
eq(actors.apId, followerApId),
|
|
428
|
-
gt(actors.followingCount, 0),
|
|
429
|
-
acceptedEdgeExists,
|
|
430
|
-
),
|
|
431
|
-
),
|
|
432
|
-
db
|
|
433
|
-
.update(actors)
|
|
434
|
-
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
435
|
-
.where(
|
|
436
|
-
and(
|
|
437
|
-
eq(actors.apId, followingApId),
|
|
438
|
-
gt(actors.followerCount, 0),
|
|
439
|
-
acceptedEdgeExists,
|
|
440
|
-
),
|
|
441
|
-
),
|
|
442
|
-
db
|
|
443
|
-
.delete(follows)
|
|
444
|
-
.where(
|
|
445
|
-
and(
|
|
446
|
-
eq(follows.followerApId, followerApId),
|
|
447
|
-
eq(follows.followingApId, followingApId),
|
|
448
|
-
),
|
|
449
|
-
),
|
|
450
|
-
]);
|
|
387
|
+
// Commit both directions together. The inbox dispatch retries failures, but
|
|
388
|
+
// there must be no interval where only one direction has been severed.
|
|
389
|
+
await severFollowPair(db, recipient.apId, actor);
|
|
451
390
|
}
|
|
452
391
|
|
|
453
392
|
// ---------------------------------------------------------------------------
|
|
@@ -461,17 +400,25 @@ export async function handleFlag(
|
|
|
461
400
|
) {
|
|
462
401
|
const objectId = getActivityObjectId(activity);
|
|
463
402
|
const targetId = getActivityTargetId(activity);
|
|
464
|
-
// Flag activities carry a free-text reason
|
|
465
|
-
|
|
466
|
-
const rawContent = (activity as { content?: unknown }).content;
|
|
403
|
+
// Flag activities carry a free-text reason at envelope-level `content`.
|
|
404
|
+
const rawContent = activity.content;
|
|
467
405
|
// Cap the inbound reason length at ingest. The Flag `content` is fully
|
|
468
406
|
// attacker-controlled free text, so without a bound the reports table grows
|
|
469
407
|
// unbounded under report spam. 2000 chars is ample for a moderation reason.
|
|
470
408
|
const content =
|
|
471
409
|
typeof rawContent === "string" ? rawContent.slice(0, 2000) : null;
|
|
472
410
|
|
|
473
|
-
//
|
|
474
|
-
|
|
411
|
+
// Standard AS2 / Mastodon Flag uses an object ARRAY (usually the reported
|
|
412
|
+
// post followed by its actor). The parser projects those references into a
|
|
413
|
+
// bounded list; retain the scalar fallback for older callers/fixtures and the
|
|
414
|
+
// target fallback for peers that use it instead of object.
|
|
415
|
+
const reportedIds = [
|
|
416
|
+
...(activity.objectIds ?? []),
|
|
417
|
+
...(objectId ? [objectId] : []),
|
|
418
|
+
...(targetId ? [targetId] : []),
|
|
419
|
+
]
|
|
420
|
+
.filter((value, index, all) => all.indexOf(value) === index)
|
|
421
|
+
.slice(0, MAX_ACTIVITY_OBJECT_IDS);
|
|
475
422
|
|
|
476
423
|
let instance: string | null = null;
|
|
477
424
|
try {
|
|
@@ -480,18 +427,90 @@ export async function handleFlag(
|
|
|
480
427
|
instance = null;
|
|
481
428
|
}
|
|
482
429
|
|
|
483
|
-
// Persist
|
|
484
|
-
//
|
|
485
|
-
//
|
|
430
|
+
// Persist only a target this instance can actually moderate:
|
|
431
|
+
// - an object authored by a local actor;
|
|
432
|
+
// - an object scoped to a live local community; or
|
|
433
|
+
// - a local actor/community itself.
|
|
434
|
+
//
|
|
435
|
+
// Before this authority check, every signed Flag — including one containing
|
|
436
|
+
// no usable object at all — appended a permanent report row for an arbitrary
|
|
437
|
+
// attacker-chosen id. Besides filling the queue with unactionable noise, that
|
|
438
|
+
// made the append-only table a storage-amplification surface. Each IN list is
|
|
439
|
+
// capped by MAX_ACTIVITY_OBJECT_IDS, below D1's 100-bind ceiling.
|
|
440
|
+
//
|
|
441
|
+
// A storage failure MUST escape to the inbox route. The shared dispatch
|
|
442
|
+
// contract releases its fenced claim and returns retryable 503, leaving the
|
|
443
|
+
// activity ledger uncommitted so the peer can complete the report later.
|
|
444
|
+
// Swallowing this error would ACK 202 and mark the activity processed even
|
|
445
|
+
// though no moderation row exists, permanently losing a valid abuse report.
|
|
486
446
|
try {
|
|
487
447
|
const db = c.get("db");
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
448
|
+
if (reportedIds.length === 0) {
|
|
449
|
+
log.info("Dropped inbound Flag with no reportable target", {
|
|
450
|
+
event: "ap.flag.no_target",
|
|
451
|
+
actor,
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const objectTargets = await db
|
|
457
|
+
.select({ apId: objects.apId })
|
|
458
|
+
.from(objects)
|
|
459
|
+
.where(
|
|
460
|
+
and(
|
|
461
|
+
inArray(objects.apId, reportedIds),
|
|
462
|
+
or(
|
|
463
|
+
sql`EXISTS (SELECT 1 FROM ${actors} WHERE ${actors.apId} = ${objects.attributedTo})`,
|
|
464
|
+
sql`EXISTS (
|
|
465
|
+
SELECT 1 FROM ${communities}
|
|
466
|
+
WHERE ${communities.apId} = ${objects.communityApId}
|
|
467
|
+
AND ${communities.deletedAt} IS NULL
|
|
468
|
+
)`,
|
|
469
|
+
),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
const actorTargets = await db
|
|
473
|
+
.select({ apId: actors.apId })
|
|
474
|
+
.from(actors)
|
|
475
|
+
.where(inArray(actors.apId, reportedIds));
|
|
476
|
+
const communityTargets = await db
|
|
477
|
+
.select({ apId: communities.apId })
|
|
478
|
+
.from(communities)
|
|
479
|
+
.where(
|
|
480
|
+
and(inArray(communities.apId, reportedIds), notDeleted(communities)),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// Prefer a specific actionable object over its actor when both are present
|
|
484
|
+
// in a standard `[post, actor]` Flag array.
|
|
485
|
+
const actionableObjects = new Set(objectTargets.map((row) => row.apId));
|
|
486
|
+
const actionableEntities = new Set([
|
|
487
|
+
...actorTargets.map((row) => row.apId),
|
|
488
|
+
...communityTargets.map((row) => row.apId),
|
|
489
|
+
]);
|
|
490
|
+
const reportTarget =
|
|
491
|
+
reportedIds.find((id) => actionableObjects.has(id)) ??
|
|
492
|
+
reportedIds.find((id) => actionableEntities.has(id));
|
|
493
|
+
if (!reportTarget) {
|
|
494
|
+
log.info("Dropped inbound Flag outside local moderation authority", {
|
|
495
|
+
event: "ap.flag.unowned_target",
|
|
496
|
+
actor,
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await db
|
|
502
|
+
.insert(reports)
|
|
503
|
+
.values({
|
|
504
|
+
// verifyAndParseInbox stamps an origin-bound internal activity id.
|
|
505
|
+
// Reusing it makes a dispatch retry idempotent instead of appending a
|
|
506
|
+
// second random report after a commit/lease failure.
|
|
507
|
+
id: activity.id || generateId(),
|
|
508
|
+
reporterApId: actor,
|
|
509
|
+
targetApId: reportTarget,
|
|
510
|
+
content,
|
|
511
|
+
instance,
|
|
512
|
+
})
|
|
513
|
+
.onConflictDoNothing();
|
|
495
514
|
} catch (err) {
|
|
496
515
|
log.warn("Failed to persist Flag report", {
|
|
497
516
|
event: "ap.flag.persist_failed",
|
|
@@ -500,6 +519,7 @@ export async function handleFlag(
|
|
|
500
519
|
target: targetId,
|
|
501
520
|
error: err,
|
|
502
521
|
});
|
|
522
|
+
throw err;
|
|
503
523
|
}
|
|
504
524
|
|
|
505
525
|
log.warn("Flag received", {
|
|
@@ -546,16 +566,13 @@ function resolveCollectionTarget(
|
|
|
546
566
|
const followingApId = normalizeCollectionTarget(targetId || actor) || null;
|
|
547
567
|
if (!followingApId) return null;
|
|
548
568
|
|
|
549
|
-
// SECURITY (
|
|
550
|
-
// attacker-controlled and only the signing actor is authenticated
|
|
551
|
-
//
|
|
552
|
-
// user's follow edge to
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
} catch {
|
|
558
|
-
return null;
|
|
559
|
-
}
|
|
569
|
+
// SECURITY (same-host actor authority): `activity.target` is
|
|
570
|
+
// attacker-controlled and only the exact signing actor is authenticated.
|
|
571
|
+
// Same-origin is insufficient: one account on a multi-user remote host must
|
|
572
|
+
// not Accept or Remove a local user's follow edge to a sibling account. Once
|
|
573
|
+
// an optional `/followers` collection suffix is normalized, the target actor
|
|
574
|
+
// id must therefore identify the verified signer under the same narrow
|
|
575
|
+
// cosmetic equivalence used at the HTTP-signature boundary.
|
|
576
|
+
if (!isSameActivityPubActor(followingApId, actor)) return null;
|
|
560
577
|
return followingApId;
|
|
561
578
|
}
|