@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
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
isNull,
|
|
10
10
|
or,
|
|
11
11
|
sql,
|
|
12
|
+
type SQLWrapper,
|
|
12
13
|
} from "drizzle-orm";
|
|
13
14
|
import {
|
|
14
15
|
communities,
|
|
15
16
|
communityJoinRequests,
|
|
16
17
|
communityMembers,
|
|
18
|
+
follows,
|
|
17
19
|
mediaUploads,
|
|
18
20
|
objects,
|
|
19
21
|
} from "../../../db/index.ts";
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
} from "./membership-shared.ts";
|
|
33
35
|
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
34
36
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
37
|
+
import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
|
|
35
38
|
import { reapReplacedMediaUrl } from "../posts/delete-cascade.ts";
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -46,6 +49,32 @@ type Batchable = {
|
|
|
46
49
|
|
|
47
50
|
const communitiesRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Public member count = role-bearing local memberships + accepted Follow-backed
|
|
54
|
+
* memberships. `communities.memberCount` intentionally remains the local-row
|
|
55
|
+
* denormalization; federated authority already lives in `follows`, so reads
|
|
56
|
+
* compose the two sources without adding a second mutable counter lifecycle.
|
|
57
|
+
*/
|
|
58
|
+
function projectedCommunityMemberCountSql(
|
|
59
|
+
communityApIdVal: string | SQLWrapper,
|
|
60
|
+
) {
|
|
61
|
+
return sql<number>`
|
|
62
|
+
${communities.memberCount} + (
|
|
63
|
+
SELECT COUNT(*)
|
|
64
|
+
FROM ${follows}
|
|
65
|
+
WHERE ${follows.followingApId} = ${communityApIdVal}
|
|
66
|
+
AND ${follows.status} = 'accepted'
|
|
67
|
+
AND ${operatorActorNotBlockedSql(sql`${follows.followerApId}`)}
|
|
68
|
+
AND NOT EXISTS (
|
|
69
|
+
SELECT 1
|
|
70
|
+
FROM ${communityMembers}
|
|
71
|
+
WHERE ${communityMembers.communityApId} = ${communityApIdVal}
|
|
72
|
+
AND ${communityMembers.actorApId} = ${follows.followerApId}
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
function isValidCommunityIconUrl(value: string): boolean {
|
|
50
79
|
const trimmed = value.trim();
|
|
51
80
|
if (trimmed.startsWith("/media/")) return true;
|
|
@@ -216,7 +245,7 @@ communitiesRouter.get("/", async (c) => {
|
|
|
216
245
|
visibility: communities.visibility,
|
|
217
246
|
joinPolicy: communities.joinPolicy,
|
|
218
247
|
postPolicy: communities.postPolicy,
|
|
219
|
-
memberCount: communities.
|
|
248
|
+
memberCount: projectedCommunityMemberCountSql(communities.apId),
|
|
220
249
|
createdAt: communities.createdAt,
|
|
221
250
|
lastMessageAt: communities.lastMessageAt,
|
|
222
251
|
})
|
|
@@ -460,19 +489,25 @@ communitiesRouter.get("/:identifier", async (c) => {
|
|
|
460
489
|
const restricted =
|
|
461
490
|
communityRequiresMembership(community.visibility) && !isMember;
|
|
462
491
|
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
492
|
+
// `communities.memberCount` is the local-row denormalization. Compose it with
|
|
493
|
+
// accepted Follow-backed membership at read time, while preserving private
|
|
494
|
+
// community redaction. Post count similarly remains an indexed projection.
|
|
495
|
+
const [memberCountRow, postCountRow] = restricted
|
|
496
|
+
? [{ count: 0 }, { count: 0 }]
|
|
497
|
+
: await Promise.all([
|
|
498
|
+
db
|
|
499
|
+
.select({
|
|
500
|
+
count: projectedCommunityMemberCountSql(community.apId),
|
|
501
|
+
})
|
|
502
|
+
.from(communities)
|
|
503
|
+
.where(eq(communities.apId, community.apId))
|
|
504
|
+
.get(),
|
|
505
|
+
db
|
|
471
506
|
.select({ count: count() })
|
|
472
507
|
.from(objects)
|
|
473
508
|
.where(eq(objects.communityApId, community.apId))
|
|
474
|
-
.get()
|
|
475
|
-
)
|
|
509
|
+
.get(),
|
|
510
|
+
]);
|
|
476
511
|
|
|
477
512
|
return c.json({
|
|
478
513
|
community: {
|
|
@@ -484,8 +519,8 @@ communitiesRouter.get("/:identifier", async (c) => {
|
|
|
484
519
|
visibility: community.visibility,
|
|
485
520
|
join_policy: community.joinPolicy,
|
|
486
521
|
post_policy: community.postPolicy,
|
|
487
|
-
member_count:
|
|
488
|
-
post_count:
|
|
522
|
+
member_count: memberCountRow?.count || 0,
|
|
523
|
+
post_count: postCountRow?.count || 0,
|
|
489
524
|
created_by: restricted ? null : community.createdBy,
|
|
490
525
|
created_at: community.createdAt,
|
|
491
526
|
is_member: isMember,
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
// GET /contacts - List DM contacts and communities
|
|
2
2
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
-
import {
|
|
5
|
-
and,
|
|
6
|
-
count,
|
|
7
|
-
desc,
|
|
8
|
-
eq,
|
|
9
|
-
inArray,
|
|
10
|
-
isNotNull,
|
|
11
|
-
isNull,
|
|
12
|
-
ne,
|
|
13
|
-
or,
|
|
14
|
-
sql,
|
|
15
|
-
} from "drizzle-orm";
|
|
4
|
+
import { and, count, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
|
|
16
5
|
import {
|
|
17
6
|
communities,
|
|
18
7
|
communityMembers,
|
|
@@ -23,10 +12,15 @@ import {
|
|
|
23
12
|
} from "../../../db/index.ts";
|
|
24
13
|
import { formatUsername } from "../../federation-helpers.ts";
|
|
25
14
|
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
15
|
+
import {
|
|
16
|
+
isActorBlockedStrict,
|
|
17
|
+
operatorActorNotBlockedSql,
|
|
18
|
+
} from "../../lib/blocklist.ts";
|
|
26
19
|
import { yurumeUnreadCounts } from "../../lib/unread-counts.ts";
|
|
27
20
|
import {
|
|
28
21
|
buildActorInfoMap,
|
|
29
22
|
byTimeDesc,
|
|
23
|
+
dmWhereForActor,
|
|
30
24
|
findRepliedConversations,
|
|
31
25
|
formatActorProfile,
|
|
32
26
|
groupConversations,
|
|
@@ -42,31 +36,10 @@ contacts.get("/contacts", async (c) => {
|
|
|
42
36
|
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
43
37
|
const db = c.get("db");
|
|
44
38
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
// that index instead of an unindexable `to_json LIKE '%"<apId>"%'` substring
|
|
50
|
-
// scan keeps the same membership semantics (recipient OR author) without a
|
|
51
|
-
// full-table scan.
|
|
52
|
-
const recipientObjectIds = db
|
|
53
|
-
.select({ objectApId: objectRecipients.objectApId })
|
|
54
|
-
.from(objectRecipients)
|
|
55
|
-
.where(
|
|
56
|
-
and(
|
|
57
|
-
eq(objectRecipients.recipientApId, actor.ap_id),
|
|
58
|
-
eq(objectRecipients.type, "to"),
|
|
59
|
-
),
|
|
60
|
-
);
|
|
61
|
-
const dmWhere = and(
|
|
62
|
-
eq(objects.visibility, "direct"),
|
|
63
|
-
eq(objects.type, "Note"),
|
|
64
|
-
isNotNull(objects.conversation),
|
|
65
|
-
or(
|
|
66
|
-
eq(objects.attributedTo, actor.ap_id),
|
|
67
|
-
inArray(objects.apId, recipientObjectIds),
|
|
68
|
-
),
|
|
69
|
-
);
|
|
39
|
+
// Shared indexed DM membership + operator-block predicate. Keeping the
|
|
40
|
+
// counterpart decision in one helper makes contacts, archived threads, and
|
|
41
|
+
// Takos tool threads agree for both incoming and outgoing messages.
|
|
42
|
+
const dmWhere = dmWhereForActor(db, actor.ap_id);
|
|
70
43
|
|
|
71
44
|
// GET must be side-effect-free: this handler no longer prunes orphaned
|
|
72
45
|
// dm_read_status rows. Read-status cleanup happens on the write paths that
|
|
@@ -154,6 +127,7 @@ contacts.get("/contacts", async (c) => {
|
|
|
154
127
|
eq(objects.visibility, "direct"),
|
|
155
128
|
ne(objects.attributedTo, actor.ap_id),
|
|
156
129
|
sql`${objects.published} > ${lastReadAt}`,
|
|
130
|
+
operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
|
|
157
131
|
),
|
|
158
132
|
)
|
|
159
133
|
.groupBy(objects.conversation);
|
|
@@ -205,7 +179,12 @@ contacts.get("/contacts", async (c) => {
|
|
|
205
179
|
communities,
|
|
206
180
|
eq(communityMembers.communityApId, communities.apId),
|
|
207
181
|
)
|
|
208
|
-
.where(
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(communityMembers.actorApId, actor.ap_id),
|
|
185
|
+
isNull(communities.deletedAt),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
209
188
|
|
|
210
189
|
// Batch get the last CHAT message for all communities to avoid N+1.
|
|
211
190
|
//
|
|
@@ -250,6 +229,7 @@ contacts.get("/contacts", async (c) => {
|
|
|
250
229
|
eq(objectRecipients.type, "audience"),
|
|
251
230
|
eq(objects.type, "Note"),
|
|
252
231
|
isNull(objects.communityApId),
|
|
232
|
+
operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
|
|
253
233
|
),
|
|
254
234
|
)
|
|
255
235
|
.orderBy(desc(objects.published))
|
|
@@ -301,6 +281,7 @@ contacts.get("/contacts", async (c) => {
|
|
|
301
281
|
ON r.community_ap_id = cm.community_ap_id
|
|
302
282
|
AND r.actor_ap_id = ${me}
|
|
303
283
|
WHERE cm.actor_ap_id = ${me}
|
|
284
|
+
AND ${operatorActorNotBlockedSql(sql.raw("o.attributed_to"))}
|
|
304
285
|
AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
|
|
305
286
|
GROUP BY cm.community_ap_id
|
|
306
287
|
`);
|
|
@@ -315,7 +296,10 @@ contacts.get("/contacts", async (c) => {
|
|
|
315
296
|
return {
|
|
316
297
|
type: "community" as const,
|
|
317
298
|
ap_id: cm.community.apId,
|
|
318
|
-
username: formatUsername(
|
|
299
|
+
username: formatUsername(
|
|
300
|
+
cm.community.apId,
|
|
301
|
+
cm.community.preferredUsername,
|
|
302
|
+
),
|
|
319
303
|
preferred_username: cm.community.preferredUsername,
|
|
320
304
|
name: cm.community.name,
|
|
321
305
|
icon_url: cm.community.iconUrl,
|
|
@@ -356,6 +340,7 @@ contacts.get("/contacts", async (c) => {
|
|
|
356
340
|
eq(objects.type, "Note"),
|
|
357
341
|
eq(objectRecipients.recipientApId, actor.ap_id),
|
|
358
342
|
eq(objectRecipients.type, "to"),
|
|
343
|
+
operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
|
|
359
344
|
),
|
|
360
345
|
);
|
|
361
346
|
|
|
@@ -402,7 +387,7 @@ contacts.get("/contact/:encodedApId", async (c) => {
|
|
|
402
387
|
visibility: communities.visibility,
|
|
403
388
|
})
|
|
404
389
|
.from(communities)
|
|
405
|
-
.where(eq(communities.apId, apId))
|
|
390
|
+
.where(and(eq(communities.apId, apId), isNull(communities.deletedAt)))
|
|
406
391
|
.get();
|
|
407
392
|
|
|
408
393
|
if (community) {
|
|
@@ -427,7 +412,7 @@ contacts.get("/contact/:encodedApId", async (c) => {
|
|
|
427
412
|
contact: {
|
|
428
413
|
type: "community" as const,
|
|
429
414
|
ap_id: community.apId,
|
|
430
|
-
username: formatUsername(community.apId),
|
|
415
|
+
username: formatUsername(community.apId, community.preferredUsername),
|
|
431
416
|
preferred_username: community.preferredUsername,
|
|
432
417
|
name: community.name,
|
|
433
418
|
icon_url: community.iconUrl,
|
|
@@ -440,6 +425,9 @@ contacts.get("/contact/:encodedApId", async (c) => {
|
|
|
440
425
|
}
|
|
441
426
|
|
|
442
427
|
// Otherwise treat it as a user (local actor or cached remote actor).
|
|
428
|
+
if (await isActorBlockedStrict(db, apId)) {
|
|
429
|
+
return c.json({ error: "Contact not found" }, 404);
|
|
430
|
+
}
|
|
443
431
|
const infoMap = await buildActorInfoMap(db, [apId]);
|
|
444
432
|
const info = infoMap.get(apId);
|
|
445
433
|
if (!info) return c.json({ error: "Contact not found" }, 404);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Shared helpers for DM conversations
|
|
2
2
|
|
|
3
3
|
import type { Context } from "hono";
|
|
4
|
-
import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
|
|
4
|
+
import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
|
|
5
5
|
import type { Database } from "../../../db/index.ts";
|
|
6
6
|
import { objectRecipients, objects } from "../../../db/index.ts";
|
|
7
7
|
import type { Env, Variables } from "../../types.ts";
|
|
8
8
|
import { safeJsonParse } from "../../federation-helpers.ts";
|
|
9
9
|
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
10
|
+
import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Indexed lookup of the object AP-IDs a given actor was addressed on as a DM
|
|
@@ -75,6 +76,13 @@ export function dmWhereForActor(db: Database, actorApId: string) {
|
|
|
75
76
|
eq(objects.attributedTo, actorApId),
|
|
76
77
|
inArray(objects.apId, recipientObjectIds(db, actorApId)),
|
|
77
78
|
),
|
|
79
|
+
operatorActorNotBlockedSql(sql`
|
|
80
|
+
CASE
|
|
81
|
+
WHEN ${objects.attributedTo} = ${actorApId}
|
|
82
|
+
THEN json_extract(${objects.toJson}, '$[0]')
|
|
83
|
+
ELSE ${objects.attributedTo}
|
|
84
|
+
END
|
|
85
|
+
`),
|
|
78
86
|
);
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Threading via conversation field
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import { and, desc, eq, inArray, like } from "drizzle-orm";
|
|
6
|
+
import { and, desc, eq, inArray, like, or } from "drizzle-orm";
|
|
7
7
|
import type { Database } from "../../../db/index.ts";
|
|
8
8
|
import {
|
|
9
9
|
activities,
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
MAX_DM_PAGE_LIMIT,
|
|
35
35
|
resolveConversationId,
|
|
36
36
|
} from "./query-helpers.ts";
|
|
37
|
+
import { recipientObjectIds } from "./conversations-helpers.ts";
|
|
37
38
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
38
39
|
import {
|
|
39
40
|
emitRealtimeBestEffort,
|
|
@@ -43,6 +44,8 @@ import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
|
43
44
|
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
44
45
|
import { validateChatAttachments } from "../../lib/attachments.ts";
|
|
45
46
|
import { logger } from "../../lib/logger.ts";
|
|
47
|
+
import { isActorBlockedStrict } from "../../lib/blocklist.ts";
|
|
48
|
+
import { deleteActivitiesCascade } from "../../lib/activity-delete-cascade.ts";
|
|
46
49
|
|
|
47
50
|
const log = logger.child({ component: "dm.messages" });
|
|
48
51
|
|
|
@@ -81,12 +84,7 @@ type SenderInfo = {
|
|
|
81
84
|
// on the 4s-polled endpoint).
|
|
82
85
|
type DmMessageRow = Pick<
|
|
83
86
|
typeof objects.$inferSelect,
|
|
84
|
-
| "
|
|
85
|
-
| "attributedTo"
|
|
86
|
-
| "content"
|
|
87
|
-
| "attachmentsJson"
|
|
88
|
-
| "published"
|
|
89
|
-
| "toJson"
|
|
87
|
+
"apId" | "attributedTo" | "content" | "attachmentsJson" | "published"
|
|
90
88
|
>;
|
|
91
89
|
|
|
92
90
|
type DmMessageResponse = {
|
|
@@ -156,7 +154,7 @@ function buildSenderFromActor(actor: {
|
|
|
156
154
|
}): SenderInfo {
|
|
157
155
|
return {
|
|
158
156
|
ap_id: actor.ap_id,
|
|
159
|
-
username: formatUsername(actor.ap_id),
|
|
157
|
+
username: formatUsername(actor.ap_id, actor.preferred_username),
|
|
160
158
|
preferred_username: actor.preferred_username,
|
|
161
159
|
name: actor.name,
|
|
162
160
|
icon_url: actor.icon_url,
|
|
@@ -177,12 +175,19 @@ async function fetchAuthorizedMessages(
|
|
|
177
175
|
limit: number,
|
|
178
176
|
before: string | undefined,
|
|
179
177
|
): Promise<{ rows: DmMessageRow[]; hasMore: boolean }> {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
178
|
+
// Authorization is part of the SQL set, not a post-query to_json filter.
|
|
179
|
+
// Hidden bto/bcc recipients intentionally do not appear in to_json; their
|
|
180
|
+
// indexed object_recipients link is the read authority used everywhere else
|
|
181
|
+
// in the DM subsystem. Keeping the predicate inside the query also makes the
|
|
182
|
+
// limit+1 pagination signal exact after authorization.
|
|
182
183
|
const baseCondition = and(
|
|
183
184
|
eq(objects.visibility, "direct"),
|
|
184
185
|
eq(objects.type, "Note"),
|
|
185
186
|
eq(objects.conversation, conversationId),
|
|
187
|
+
or(
|
|
188
|
+
eq(objects.attributedTo, actorApId),
|
|
189
|
+
inArray(objects.apId, recipientObjectIds(db, actorApId)),
|
|
190
|
+
),
|
|
186
191
|
);
|
|
187
192
|
|
|
188
193
|
// Composite (published, apId) cursor so two messages sharing a published
|
|
@@ -203,24 +208,14 @@ async function fetchAuthorizedMessages(
|
|
|
203
208
|
content: objects.content,
|
|
204
209
|
attachmentsJson: objects.attachmentsJson,
|
|
205
210
|
published: objects.published,
|
|
206
|
-
toJson: objects.toJson,
|
|
207
211
|
})
|
|
208
212
|
.from(objects)
|
|
209
213
|
.where(whereClause!)
|
|
210
214
|
.orderBy(desc(objects.published), desc(objects.apId))
|
|
211
215
|
.limit(limit + 1);
|
|
212
216
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// an older page.)
|
|
216
|
-
const authorized = messages.filter((msg) => {
|
|
217
|
-
if (msg.attributedTo === actorApId) return true;
|
|
218
|
-
const toRecipients = safeJsonParse<string[]>(msg.toJson, []);
|
|
219
|
-
return toRecipients.includes(actorApId);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const hasMore = authorized.length > limit;
|
|
223
|
-
return { rows: hasMore ? authorized.slice(0, limit) : authorized, hasMore };
|
|
217
|
+
const hasMore = messages.length > limit;
|
|
218
|
+
return { rows: hasMore ? messages.slice(0, limit) : messages, hasMore };
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
/** Build a map from ap_id -> actor info, checking local actors then cached actors. */
|
|
@@ -273,7 +268,7 @@ function formatMessages(
|
|
|
273
268
|
id: msg.apId,
|
|
274
269
|
sender: {
|
|
275
270
|
ap_id: msg.attributedTo,
|
|
276
|
-
username: formatUsername(msg.attributedTo),
|
|
271
|
+
username: formatUsername(msg.attributedTo, info?.preferredUsername),
|
|
277
272
|
preferred_username: info?.preferredUsername || null,
|
|
278
273
|
name: info?.name || null,
|
|
279
274
|
icon_url: info?.iconUrl || null,
|
|
@@ -379,6 +374,9 @@ dm.get("/user/:encodedApId/messages", async (c) => {
|
|
|
379
374
|
|
|
380
375
|
const db = c.get("db");
|
|
381
376
|
const otherApId = decodeURIComponent(c.req.param("encodedApId"));
|
|
377
|
+
if (await isActorBlockedStrict(db, otherApId)) {
|
|
378
|
+
return c.json({ error: "User not found" }, 404);
|
|
379
|
+
}
|
|
382
380
|
const limit = parseLimit(c.req.query("limit"), 50, MAX_DM_PAGE_LIMIT);
|
|
383
381
|
const before = c.req.query("before");
|
|
384
382
|
// Resolve to the STORED conversation id of an existing thread rather than
|
|
@@ -429,6 +427,13 @@ dm.post("/user/:encodedApId/messages", async (c) => {
|
|
|
429
427
|
|
|
430
428
|
const db = c.get("db");
|
|
431
429
|
const otherApId = decodeURIComponent(c.req.param("encodedApId"));
|
|
430
|
+
// Operator defederation is an authority boundary, not merely a late queue
|
|
431
|
+
// filter. Reject before recipient lookup, body work, conversation resolution,
|
|
432
|
+
// or any Note/Activity write so the API cannot claim a DM was sent when its
|
|
433
|
+
// delivery is guaranteed to be skipped.
|
|
434
|
+
if (await isActorBlockedStrict(db, otherApId)) {
|
|
435
|
+
return c.json({ error: "User not found" }, 404);
|
|
436
|
+
}
|
|
432
437
|
const body = await c.req.json<{
|
|
433
438
|
content?: string;
|
|
434
439
|
attachments?: unknown;
|
|
@@ -708,25 +713,14 @@ dm.delete("/messages/:messageId", async (c) => {
|
|
|
708
713
|
}
|
|
709
714
|
const message = messageOrError;
|
|
710
715
|
|
|
711
|
-
// Sequential operations (D1 doesn't support interactive
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
// with no FK
|
|
715
|
-
// because the notifications query LEFT JOINs the now-missing
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
const relatedActivities = await db
|
|
720
|
-
.select({ apId: activities.apId })
|
|
721
|
-
.from(activities)
|
|
722
|
-
.where(eq(activities.objectApId, message.apId));
|
|
723
|
-
const activityIds = relatedActivities.map((a) => a.apId);
|
|
724
|
-
if (activityIds.length > 0) {
|
|
725
|
-
await db
|
|
726
|
-
.delete(inboxTable)
|
|
727
|
-
.where(inArray(inboxTable.activityApId, activityIds));
|
|
728
|
-
await db.delete(activities).where(inArray(activities.apId, activityIds));
|
|
729
|
-
}
|
|
716
|
+
// Sequential object/media operations (D1 doesn't support interactive
|
|
717
|
+
// transactions). First atomically remove every delivery Create activity and
|
|
718
|
+
// its activity-keyed inbox/push/delivery/archive/claim projections. These
|
|
719
|
+
// tables are addressed by AP id with no object FK, so deleting only the object
|
|
720
|
+
// orphans them — and because the notifications query LEFT JOINs the now-missing
|
|
721
|
+
// object (NULL visibility → not excluded as "direct"), an orphan inbox row
|
|
722
|
+
// resurfaces as a blank "mention" with a dead /post link.
|
|
723
|
+
await deleteActivitiesCascade(db, eq(activities.objectApId, message.apId));
|
|
730
724
|
// Reap the message's child rows AND any attached R2 blob + media_uploads row
|
|
731
725
|
// via the shared cascade (covers objectRecipients + media + likes/announces/
|
|
732
726
|
// bookmarks/story*), then drop the object. Local DMs are text-only today so
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// DM read status and archive management
|
|
2
2
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
-
import { and, desc, eq } from "drizzle-orm";
|
|
4
|
+
import { and, desc, eq, isNull } from "drizzle-orm";
|
|
5
5
|
import {
|
|
6
6
|
communities,
|
|
7
7
|
communityMembers,
|
|
@@ -95,7 +95,9 @@ readArchive.post("/community/:encodedApId/read", async (c) => {
|
|
|
95
95
|
const community = await db
|
|
96
96
|
.select({ apId: communities.apId })
|
|
97
97
|
.from(communities)
|
|
98
|
-
.where(
|
|
98
|
+
.where(
|
|
99
|
+
and(eq(communities.apId, communityApId), isNull(communities.deletedAt)),
|
|
100
|
+
)
|
|
99
101
|
.get();
|
|
100
102
|
if (!community) return c.json({ error: "Community not found" }, 404);
|
|
101
103
|
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
// DM requests - list, accept, reject
|
|
2
2
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
-
import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
|
5
|
-
import {
|
|
6
|
-
activities,
|
|
7
|
-
blocks,
|
|
8
|
-
inbox as inboxTable,
|
|
9
|
-
objectRecipients,
|
|
10
|
-
objects,
|
|
11
|
-
} from "../../../db/index.ts";
|
|
4
|
+
import { and, asc, desc, eq, gt, inArray, isNotNull, sql } from "drizzle-orm";
|
|
5
|
+
import { activities, blocks, objects } from "../../../db/index.ts";
|
|
12
6
|
import { resolveConversationId } from "./query-helpers.ts";
|
|
13
7
|
import {
|
|
14
8
|
buildActorInfoMap,
|
|
@@ -17,6 +11,13 @@ import {
|
|
|
17
11
|
type HonoEnv,
|
|
18
12
|
recipientObjectIds,
|
|
19
13
|
} from "./conversations-helpers.ts";
|
|
14
|
+
import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
|
|
15
|
+
import { deleteActivitiesCascade } from "../../lib/activity-delete-cascade.ts";
|
|
16
|
+
import { D1_IN_CHUNK } from "../../lib/chunk.ts";
|
|
17
|
+
import {
|
|
18
|
+
deleteObjectsCascade,
|
|
19
|
+
purgeMediaBlobs,
|
|
20
|
+
} from "../posts/delete-cascade.ts";
|
|
20
21
|
|
|
21
22
|
// Upper bound on the number of pending-request CONVERSATIONS returned. The list
|
|
22
23
|
// is collapsed to one row per conversation in SQL (GROUP BY), so this bounds
|
|
@@ -58,6 +59,7 @@ requests.get("/requests", async (c) => {
|
|
|
58
59
|
// instead of an unindexable `to_json LIKE '%"<apId>"%'` scan; same
|
|
59
60
|
// recipient-membership semantics.
|
|
60
61
|
inArray(objects.apId, recipientObjectIds(db, actor.ap_id)),
|
|
62
|
+
operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
|
|
61
63
|
),
|
|
62
64
|
)
|
|
63
65
|
.groupBy(objects.conversation)
|
|
@@ -121,56 +123,62 @@ requests.post("/requests/reject", async (c) => {
|
|
|
121
123
|
body.sender_ap_id,
|
|
122
124
|
);
|
|
123
125
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
126
|
+
// One exact owner predicate for every cleanup stage. A legacy/corrupt public
|
|
127
|
+
// row can share the conversation string; omitting the direct-Note scope from
|
|
128
|
+
// only the Activity/recipient subqueries would delete that survivor's
|
|
129
|
+
// projections while leaving its object behind.
|
|
130
|
+
const senderMessageWhere = (afterApId?: string) =>
|
|
131
|
+
and(
|
|
132
|
+
eq(objects.conversation, conversationId),
|
|
133
|
+
eq(objects.visibility, "direct"),
|
|
134
|
+
eq(objects.type, "Note"),
|
|
135
|
+
eq(objects.attributedTo, body.sender_ap_id),
|
|
136
|
+
afterApId ? gt(objects.apId, afterApId) : undefined,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// The complete sender message set stays a subquery here (never materialised
|
|
140
|
+
// into one unbounded `IN (...)`) so Activity hard deletion remains one
|
|
141
|
+
// constant-parameter atomic unit even after a spammer writes >100 messages.
|
|
127
142
|
const senderObjectIds = db
|
|
128
143
|
.select({ apId: objects.apId })
|
|
129
144
|
.from(objects)
|
|
130
|
-
.where(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
.where(senderMessageWhere());
|
|
146
|
+
|
|
147
|
+
// Drop every delivery Create activity and its durable projections FIRST,
|
|
148
|
+
// before the objects vanish. These tables are addressed by AP id with no FK
|
|
149
|
+
// to `objects`, so deleting only the object orphans them — and the
|
|
150
|
+
// notifications query LEFT JOINs the now-missing object (gone → NULL
|
|
151
|
+
// visibility → no longer excluded as "direct"), so each orphan Create would
|
|
152
|
+
// resurface as a blank "mention" with a dead link plus an inflated unread
|
|
153
|
+
// badge while push/delivery workers churn. This mirrors DM message deletion.
|
|
154
|
+
await deleteActivitiesCascade(
|
|
155
|
+
db,
|
|
156
|
+
inArray(activities.objectApId, senderObjectIds),
|
|
157
|
+
);
|
|
136
158
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// conversation (same subquery-not-IN reasoning as above).
|
|
161
|
-
await db
|
|
162
|
-
.delete(objectRecipients)
|
|
163
|
-
.where(inArray(objectRecipients.objectApId, senderObjectIds));
|
|
164
|
-
|
|
165
|
-
await db
|
|
166
|
-
.delete(objects)
|
|
167
|
-
.where(
|
|
168
|
-
and(
|
|
169
|
-
eq(objects.conversation, conversationId),
|
|
170
|
-
eq(objects.visibility, "direct"),
|
|
171
|
-
eq(objects.attributedTo, body.sender_ap_id),
|
|
172
|
-
),
|
|
173
|
-
);
|
|
159
|
+
// Drain object ids in D1-safe keyset pages. Each page routes through the same
|
|
160
|
+
// semantic child + durable projection + media owner as single-message
|
|
161
|
+
// deletion, then drops the objects and finally performs the irreversible R2
|
|
162
|
+
// purge. This avoids both an unbounded parameter list and loading a spammer's
|
|
163
|
+
// complete private history into isolate memory.
|
|
164
|
+
let cursor: string | undefined;
|
|
165
|
+
while (true) {
|
|
166
|
+
const page = await db
|
|
167
|
+
.select({ apId: objects.apId })
|
|
168
|
+
.from(objects)
|
|
169
|
+
.where(senderMessageWhere(cursor))
|
|
170
|
+
.orderBy(asc(objects.apId))
|
|
171
|
+
.limit(D1_IN_CHUNK);
|
|
172
|
+
if (page.length === 0) break;
|
|
173
|
+
|
|
174
|
+
const apIds = page.map((row) => row.apId);
|
|
175
|
+
const mediaKeys = await deleteObjectsCascade(db, apIds, c.env.MEDIA);
|
|
176
|
+
await db.delete(objects).where(inArray(objects.apId, apIds));
|
|
177
|
+
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
178
|
+
|
|
179
|
+
cursor = apIds.at(-1);
|
|
180
|
+
if (!cursor || page.length < D1_IN_CHUNK) break;
|
|
181
|
+
}
|
|
174
182
|
|
|
175
183
|
if (body.block) {
|
|
176
184
|
await db
|