@takosjp/yurucommu-core 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue batch processing - handles fanout, actor resolution, reconciliation,
|
|
3
|
+
* and batch dispatch of delivery messages.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message, MessageBatch } from "@cloudflare/workers-types";
|
|
7
|
+
import type { Env } from "../../types.ts";
|
|
8
|
+
import type { Database } from "../../../db/index.ts";
|
|
9
|
+
import { and, eq, or, sql } from "drizzle-orm";
|
|
10
|
+
import {
|
|
11
|
+
activities,
|
|
12
|
+
actorCache,
|
|
13
|
+
communityMembers,
|
|
14
|
+
deliveryQueue,
|
|
15
|
+
follows,
|
|
16
|
+
inbox as inboxTable,
|
|
17
|
+
} from "../../../db/index.ts";
|
|
18
|
+
import { isLocal, isSafeRemoteUrl } from "../../federation-helpers.ts";
|
|
19
|
+
import { isActorBlocked } from "../blocklist.ts";
|
|
20
|
+
import { planEndpointsFromActorCache } from "./planner.ts";
|
|
21
|
+
import {
|
|
22
|
+
fetchAndUpsertActorCache,
|
|
23
|
+
getInstanceFetchSignerByDb,
|
|
24
|
+
} from "../activitypub-actor-cache.ts";
|
|
25
|
+
import {
|
|
26
|
+
DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
27
|
+
type DeliveryFanoutCommunityMessageV1,
|
|
28
|
+
type DeliveryFanoutFollowersMessageV1,
|
|
29
|
+
type DeliveryQueueMessageV1,
|
|
30
|
+
type DeliveryReconcileJobMessageV1,
|
|
31
|
+
type DeliveryResolveActorMessageV1,
|
|
32
|
+
} from "./types.ts";
|
|
33
|
+
import {
|
|
34
|
+
computeDeliveryJobId,
|
|
35
|
+
DELIVERY_ENDPOINT_CACHE_TTL_MS,
|
|
36
|
+
safeParseIsoTimeMs,
|
|
37
|
+
} from "./transformers.ts";
|
|
38
|
+
import {
|
|
39
|
+
buildDeliverEndpointMessage,
|
|
40
|
+
buildResolveActorMessage,
|
|
41
|
+
MAX_RECONCILE_ATTEMPTS,
|
|
42
|
+
nowIso,
|
|
43
|
+
type QueueEnv,
|
|
44
|
+
requireQueue,
|
|
45
|
+
sendQueueMessage,
|
|
46
|
+
upsertDeliveryJob,
|
|
47
|
+
} from "./queue.ts";
|
|
48
|
+
import { logger } from "../logger.ts";
|
|
49
|
+
|
|
50
|
+
const DELIVERY_HTTP_TIMEOUT_MS = 8000;
|
|
51
|
+
// Cap on resolve_actor self-requeues (each ~60s apart) before giving up on a
|
|
52
|
+
// permanently-unresolvable recipient — otherwise a dead host churns one queue
|
|
53
|
+
// message every 60s forever.
|
|
54
|
+
const MAX_RESOLVE_ATTEMPTS = 8;
|
|
55
|
+
|
|
56
|
+
const log = logger.child({ component: "delivery.batching" });
|
|
57
|
+
|
|
58
|
+
async function fetchAndCacheRemoteActor(
|
|
59
|
+
db: Database,
|
|
60
|
+
actorApId: string,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
await fetchAndUpsertActorCache(db, actorApId, {
|
|
63
|
+
timeout: DELIVERY_HTTP_TIMEOUT_MS,
|
|
64
|
+
mode: "upsert",
|
|
65
|
+
// Sign as the instance actor so resolving a delivery target on a
|
|
66
|
+
// secure-mode instance doesn't 401 (unsigned otherwise).
|
|
67
|
+
signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolvePreferredEndpoint(
|
|
72
|
+
row: { inbox: string | null; sharedInbox: string | null } | null,
|
|
73
|
+
): string | null {
|
|
74
|
+
if (row?.sharedInbox && isSafeRemoteUrl(row.sharedInbox)) {
|
|
75
|
+
return row.sharedInbox;
|
|
76
|
+
}
|
|
77
|
+
if (row?.inbox && isSafeRemoteUrl(row.inbox)) return row.inbox;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fan-out is paginated and chunked so a single popular-actor delivery cannot
|
|
82
|
+
// (a) materialize an unbounded follower set in one Worker invocation, nor
|
|
83
|
+
// (b) exceed Cloudflare Queues' 100-messages-per-`sendBatch` limit (which
|
|
84
|
+
// would throw before `message.ack()` and retry-loop forever). Mirrors the
|
|
85
|
+
// page/chunk/cap pattern in `enqueueResolveForEndpointActors`.
|
|
86
|
+
const FANOUT_FOLLOWER_PAGE_SIZE = 200;
|
|
87
|
+
const FANOUT_SEND_BATCH_SIZE = 100;
|
|
88
|
+
const FANOUT_MAX_FOLLOWERS = 20_000;
|
|
89
|
+
|
|
90
|
+
async function sendQueueBatchChunked(
|
|
91
|
+
queue: QueueEnv["DELIVERY_QUEUE"],
|
|
92
|
+
requests: Array<{ body: DeliveryQueueMessageV1 }>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
for (let i = 0; i < requests.length; i += FANOUT_SEND_BATCH_SIZE) {
|
|
95
|
+
await queue.sendBatch(requests.slice(i, i + FANOUT_SEND_BATCH_SIZE));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Page through the accepted-follower graph of `followeeApId`, plan each page's
|
|
101
|
+
* remote recipients against the actor cache, and enqueue `deliver_endpoint`
|
|
102
|
+
* (known endpoints) + `resolve_actor` (unknown recipients) jobs directly.
|
|
103
|
+
*
|
|
104
|
+
* This is the shared core of follower fan-out. It is deliberately decoupled
|
|
105
|
+
* from the queue message so it can also be driven SYNCHRONOUSLY by callers
|
|
106
|
+
* that must capture a follower snapshot before the `follows` rows are deleted
|
|
107
|
+
* (e.g. account deletion teardown in routes/actors.ts): the async
|
|
108
|
+
* `fanout_followers` consumer would otherwise read an already-emptied graph
|
|
109
|
+
* and deliver the Delete(actor) to zero followers.
|
|
110
|
+
*
|
|
111
|
+
* Returns the number of follower rows scanned and whether the per-invocation
|
|
112
|
+
* cap was hit (so callers can log the same capped warning).
|
|
113
|
+
*/
|
|
114
|
+
export async function enqueueFollowerEndpointDeliveries(
|
|
115
|
+
db: Database,
|
|
116
|
+
queue: QueueEnv["DELIVERY_QUEUE"],
|
|
117
|
+
baseUrl: string,
|
|
118
|
+
activityId: string,
|
|
119
|
+
followeeApId: string,
|
|
120
|
+
): Promise<{ processed: number; capped: boolean }> {
|
|
121
|
+
// Page through accepted followers with a keyset cursor instead of loading
|
|
122
|
+
// every row into memory at once. Each page is planned and dispatched in
|
|
123
|
+
// ≤100-message chunks before the next page is read, bounding both memory
|
|
124
|
+
// and per-call batch size.
|
|
125
|
+
let cursor: string | null = null;
|
|
126
|
+
let processed = 0;
|
|
127
|
+
let capped = false;
|
|
128
|
+
|
|
129
|
+
while (processed < FANOUT_MAX_FOLLOWERS) {
|
|
130
|
+
const conditions = [
|
|
131
|
+
eq(follows.followingApId, followeeApId),
|
|
132
|
+
eq(follows.status, "accepted"),
|
|
133
|
+
];
|
|
134
|
+
if (cursor !== null) {
|
|
135
|
+
conditions.push(sql`${follows.followerApId} > ${cursor}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const page = await db
|
|
139
|
+
.select({ followerApId: follows.followerApId })
|
|
140
|
+
.from(follows)
|
|
141
|
+
.where(and(...conditions))
|
|
142
|
+
.orderBy(follows.followerApId)
|
|
143
|
+
.limit(FANOUT_FOLLOWER_PAGE_SIZE);
|
|
144
|
+
|
|
145
|
+
if (page.length === 0) break;
|
|
146
|
+
|
|
147
|
+
cursor = page[page.length - 1].followerApId;
|
|
148
|
+
|
|
149
|
+
// Deduplicate within the page and drop local recipients (no remote
|
|
150
|
+
// delivery needed for local followers).
|
|
151
|
+
const recipientApIds = [...new Set(page.map((f) => f.followerApId))].filter(
|
|
152
|
+
(apId) => !isLocal(apId, baseUrl),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (recipientApIds.length > 0) {
|
|
156
|
+
const planned = await planEndpointsFromActorCache(db, recipientApIds, {
|
|
157
|
+
metricTags: {
|
|
158
|
+
followee: followeeApId,
|
|
159
|
+
activity: activityId,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
|
|
164
|
+
for (const group of planned.groups) {
|
|
165
|
+
const jobId = await computeDeliveryJobId(activityId, group.endpoint);
|
|
166
|
+
await upsertDeliveryJob(db, jobId, activityId, group.endpoint);
|
|
167
|
+
deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const resolveRequests = planned.unknownRecipients.map((apId) => ({
|
|
171
|
+
body: buildResolveActorMessage(activityId, apId),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
await sendQueueBatchChunked(queue, deliverRequests);
|
|
175
|
+
await sendQueueBatchChunked(queue, resolveRequests);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
processed += page.length;
|
|
179
|
+
|
|
180
|
+
if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
|
|
181
|
+
if (processed >= FANOUT_MAX_FOLLOWERS) {
|
|
182
|
+
capped = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { processed, capped };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Synchronously snapshot an actor's follower inboxes and enqueue per-endpoint
|
|
192
|
+
* delivery jobs for `activityId`, BEFORE the caller deletes the `follows`
|
|
193
|
+
* rows. Use this from teardown paths (account deletion) where the async
|
|
194
|
+
* `fanout_followers` consumer would otherwise run after the follower graph is
|
|
195
|
+
* gone and reach zero remote followers.
|
|
196
|
+
*
|
|
197
|
+
* Best-effort and queue-aware: if the delivery queue bindings are missing it
|
|
198
|
+
* is a no-op (mirrors enqueueFanoutToFollowers' silent producer-unavailable
|
|
199
|
+
* behavior). Never throws into the caller's teardown transaction — the caller
|
|
200
|
+
* still wraps it so federation can never block local deletion.
|
|
201
|
+
*/
|
|
202
|
+
export async function snapshotAndEnqueueFollowerDeliveries(
|
|
203
|
+
db: Database,
|
|
204
|
+
env: Env,
|
|
205
|
+
activityId: string,
|
|
206
|
+
followeeApId: string,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
const queue = (env as Partial<QueueEnv>).DELIVERY_QUEUE;
|
|
209
|
+
if (!queue) {
|
|
210
|
+
log.warn("Delivery queue unavailable; follower snapshot delivery skipped", {
|
|
211
|
+
event: "delivery.fanout.snapshot_queue_unavailable",
|
|
212
|
+
followee: followeeApId,
|
|
213
|
+
activityId,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { processed, capped } = await enqueueFollowerEndpointDeliveries(
|
|
219
|
+
db,
|
|
220
|
+
queue,
|
|
221
|
+
env.APP_URL,
|
|
222
|
+
activityId,
|
|
223
|
+
followeeApId,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (capped) {
|
|
227
|
+
log.warn("Follower snapshot delivery capped at max followers", {
|
|
228
|
+
event: "delivery.fanout.snapshot_capped",
|
|
229
|
+
followee: followeeApId,
|
|
230
|
+
activityId,
|
|
231
|
+
processed,
|
|
232
|
+
max: FANOUT_MAX_FOLLOWERS,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function processFanoutFollowers(
|
|
238
|
+
db: Database,
|
|
239
|
+
env: Env,
|
|
240
|
+
msg: DeliveryFanoutFollowersMessageV1,
|
|
241
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
if (!requireQueue(env, "fanout", message)) return;
|
|
244
|
+
const queueEnv = env as QueueEnv;
|
|
245
|
+
|
|
246
|
+
const { processed, capped } = await enqueueFollowerEndpointDeliveries(
|
|
247
|
+
db,
|
|
248
|
+
queueEnv.DELIVERY_QUEUE,
|
|
249
|
+
env.APP_URL,
|
|
250
|
+
msg.activityId,
|
|
251
|
+
msg.followeeApId,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (capped) {
|
|
255
|
+
// Extremely large follower sets are capped per invocation to keep the
|
|
256
|
+
// Worker within CPU/time limits. Endpoint-deduped delivery jobs are
|
|
257
|
+
// idempotent (computeDeliveryJobId + upsertDeliveryJob), and the planner
|
|
258
|
+
// re-enqueues any still-unknown recipients on the next fanout, so capped
|
|
259
|
+
// followers are re-planned on the actor's next delivery rather than lost
|
|
260
|
+
// silently.
|
|
261
|
+
log.warn("Fanout capped at max followers for one invocation", {
|
|
262
|
+
event: "delivery.fanout.capped",
|
|
263
|
+
followee: msg.followeeApId,
|
|
264
|
+
activityId: msg.activityId,
|
|
265
|
+
processed,
|
|
266
|
+
max: FANOUT_MAX_FOLLOWERS,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
message.ack();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Fan an activity out to a community's audience instead of the author's
|
|
275
|
+
* personal follower graph. The community is a Group-style actor:
|
|
276
|
+
*
|
|
277
|
+
* - LOCAL recipients (accepted `communityMembers` hosted on this server,
|
|
278
|
+
* excluding the author) receive an inbox entry directly, so local members
|
|
279
|
+
* see the post even though it never touched the author's follower set.
|
|
280
|
+
* - REMOTE recipients (remote `communityMembers` plus accepted followers of
|
|
281
|
+
* the community actor in `follows`) are planned to their inbox/sharedInbox
|
|
282
|
+
* endpoints and delivered like a normal remote fan-out.
|
|
283
|
+
*
|
|
284
|
+
* This keeps reach == community: a community post is delivered to community
|
|
285
|
+
* members, never to the author's plain followers.
|
|
286
|
+
*/
|
|
287
|
+
export async function processFanoutCommunity(
|
|
288
|
+
db: Database,
|
|
289
|
+
env: Env,
|
|
290
|
+
msg: DeliveryFanoutCommunityMessageV1,
|
|
291
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const baseUrl = env.APP_URL;
|
|
294
|
+
|
|
295
|
+
if (!requireQueue(env, "fanout_community", message)) return;
|
|
296
|
+
const queueEnv = env as QueueEnv;
|
|
297
|
+
|
|
298
|
+
// Resolve the activity's author so we never echo the post back into the
|
|
299
|
+
// author's own inbox.
|
|
300
|
+
const activityRow = await db
|
|
301
|
+
.select({ actorApId: activities.actorApId })
|
|
302
|
+
.from(activities)
|
|
303
|
+
.where(eq(activities.apId, msg.activityId))
|
|
304
|
+
.get();
|
|
305
|
+
const authorApId = activityRow?.actorApId ?? null;
|
|
306
|
+
|
|
307
|
+
// ----- 1. Local members: deliver to their inbox directly. ----------------
|
|
308
|
+
let memberCursor: string | null = null;
|
|
309
|
+
while (true) {
|
|
310
|
+
const conditions = [eq(communityMembers.communityApId, msg.communityApId)];
|
|
311
|
+
if (memberCursor !== null) {
|
|
312
|
+
conditions.push(sql`${communityMembers.actorApId} > ${memberCursor}`);
|
|
313
|
+
}
|
|
314
|
+
const page = await db
|
|
315
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
316
|
+
.from(communityMembers)
|
|
317
|
+
.where(and(...conditions))
|
|
318
|
+
.orderBy(communityMembers.actorApId)
|
|
319
|
+
.limit(FANOUT_FOLLOWER_PAGE_SIZE);
|
|
320
|
+
|
|
321
|
+
if (page.length === 0) break;
|
|
322
|
+
memberCursor = page[page.length - 1].actorApId;
|
|
323
|
+
|
|
324
|
+
const localRecipients = page
|
|
325
|
+
.map((m) => m.actorApId)
|
|
326
|
+
.filter((apId) => isLocal(apId, baseUrl) && apId !== authorApId);
|
|
327
|
+
|
|
328
|
+
if (localRecipients.length > 0) {
|
|
329
|
+
const now = nowIso();
|
|
330
|
+
await db
|
|
331
|
+
.insert(inboxTable)
|
|
332
|
+
.values(
|
|
333
|
+
localRecipients.map((actorApId) => ({
|
|
334
|
+
actorApId,
|
|
335
|
+
activityApId: msg.activityId,
|
|
336
|
+
read: 0,
|
|
337
|
+
createdAt: now,
|
|
338
|
+
})),
|
|
339
|
+
)
|
|
340
|
+
.onConflictDoNothing();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ----- 2. Remote recipients: plan endpoints and deliver. -----------------
|
|
347
|
+
// Remote members of the community plus accepted followers of the community
|
|
348
|
+
// actor. A community member set is typically modest; followers of the
|
|
349
|
+
// community actor capture remote servers that follow the Group to receive
|
|
350
|
+
// its activities. Both are deduped before planning.
|
|
351
|
+
const remoteRecipients = new Set<string>();
|
|
352
|
+
|
|
353
|
+
// Remote community members.
|
|
354
|
+
{
|
|
355
|
+
let cursor: string | null = null;
|
|
356
|
+
let processed = 0;
|
|
357
|
+
while (processed < FANOUT_MAX_FOLLOWERS) {
|
|
358
|
+
const conditions = [
|
|
359
|
+
eq(communityMembers.communityApId, msg.communityApId),
|
|
360
|
+
];
|
|
361
|
+
if (cursor !== null) {
|
|
362
|
+
conditions.push(sql`${communityMembers.actorApId} > ${cursor}`);
|
|
363
|
+
}
|
|
364
|
+
const page = await db
|
|
365
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
366
|
+
.from(communityMembers)
|
|
367
|
+
.where(and(...conditions))
|
|
368
|
+
.orderBy(communityMembers.actorApId)
|
|
369
|
+
.limit(FANOUT_FOLLOWER_PAGE_SIZE);
|
|
370
|
+
if (page.length === 0) break;
|
|
371
|
+
cursor = page[page.length - 1].actorApId;
|
|
372
|
+
for (const m of page) {
|
|
373
|
+
if (!isLocal(m.actorApId, baseUrl) && m.actorApId !== authorApId) {
|
|
374
|
+
remoteRecipients.add(m.actorApId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
processed += page.length;
|
|
378
|
+
if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Accepted followers of the community actor.
|
|
383
|
+
{
|
|
384
|
+
let cursor: string | null = null;
|
|
385
|
+
let processed = 0;
|
|
386
|
+
while (processed < FANOUT_MAX_FOLLOWERS) {
|
|
387
|
+
const conditions = [
|
|
388
|
+
eq(follows.followingApId, msg.communityApId),
|
|
389
|
+
eq(follows.status, "accepted"),
|
|
390
|
+
];
|
|
391
|
+
if (cursor !== null) {
|
|
392
|
+
conditions.push(sql`${follows.followerApId} > ${cursor}`);
|
|
393
|
+
}
|
|
394
|
+
const page = await db
|
|
395
|
+
.select({ followerApId: follows.followerApId })
|
|
396
|
+
.from(follows)
|
|
397
|
+
.where(and(...conditions))
|
|
398
|
+
.orderBy(follows.followerApId)
|
|
399
|
+
.limit(FANOUT_FOLLOWER_PAGE_SIZE);
|
|
400
|
+
if (page.length === 0) break;
|
|
401
|
+
cursor = page[page.length - 1].followerApId;
|
|
402
|
+
for (const f of page) {
|
|
403
|
+
if (
|
|
404
|
+
!isLocal(f.followerApId, baseUrl) &&
|
|
405
|
+
f.followerApId !== authorApId
|
|
406
|
+
) {
|
|
407
|
+
remoteRecipients.add(f.followerApId);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
processed += page.length;
|
|
411
|
+
if (page.length < FANOUT_FOLLOWER_PAGE_SIZE) break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const remoteList = [...remoteRecipients];
|
|
416
|
+
if (remoteList.length > 0) {
|
|
417
|
+
// Announce-relay: remote followers receive the GROUP's Announce of the post
|
|
418
|
+
// (when present) rather than the raw author activity, so it is attributed
|
|
419
|
+
// to the community. Local members above kept the raw `activityId`.
|
|
420
|
+
const remoteActivityId = msg.announceActivityId ?? msg.activityId;
|
|
421
|
+
const planned = await planEndpointsFromActorCache(db, remoteList, {
|
|
422
|
+
metricTags: {
|
|
423
|
+
community: msg.communityApId,
|
|
424
|
+
activity: remoteActivityId,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const deliverRequests: Array<{ body: DeliveryQueueMessageV1 }> = [];
|
|
429
|
+
for (const group of planned.groups) {
|
|
430
|
+
const jobId = await computeDeliveryJobId(
|
|
431
|
+
remoteActivityId,
|
|
432
|
+
group.endpoint,
|
|
433
|
+
);
|
|
434
|
+
await upsertDeliveryJob(db, jobId, remoteActivityId, group.endpoint);
|
|
435
|
+
deliverRequests.push({ body: buildDeliverEndpointMessage(jobId) });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const resolveRequests = planned.unknownRecipients.map((apId) => ({
|
|
439
|
+
body: buildResolveActorMessage(remoteActivityId, apId),
|
|
440
|
+
}));
|
|
441
|
+
|
|
442
|
+
await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, deliverRequests);
|
|
443
|
+
await sendQueueBatchChunked(queueEnv.DELIVERY_QUEUE, resolveRequests);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// TODO(remote-inbox-optimization): when the community has a large remote
|
|
447
|
+
// footprint, prefer delivering once to each remote server's shared inbox via
|
|
448
|
+
// the community's own followers collection rather than expanding the full
|
|
449
|
+
// member/follower set here. Local delivery and community audience/addressing
|
|
450
|
+
// are already correct; this is purely a remote fan-out efficiency follow-up.
|
|
451
|
+
|
|
452
|
+
message.ack();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function processResolveActor(
|
|
456
|
+
db: Database,
|
|
457
|
+
env: Env,
|
|
458
|
+
msg: DeliveryResolveActorMessageV1,
|
|
459
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
if (!requireQueue(env, "resolve_actor", message)) return;
|
|
462
|
+
|
|
463
|
+
// Defense-in-depth: the fanout/enqueue side already drops blocked recipients
|
|
464
|
+
// via planEndpointsFromActorCache, but enforce the operator blocklist at the
|
|
465
|
+
// resolve seam too so a re-resolved actor (or a domain blocked after enqueue)
|
|
466
|
+
// never gets a delivery job. ACK silently — same posture as the inbox handler.
|
|
467
|
+
if (await isActorBlocked(db, msg.recipientActorApId)) {
|
|
468
|
+
message.ack();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const cached = await db
|
|
473
|
+
.select({
|
|
474
|
+
apId: actorCache.apId,
|
|
475
|
+
inbox: actorCache.inbox,
|
|
476
|
+
sharedInbox: actorCache.sharedInbox,
|
|
477
|
+
lastFetchedAt: actorCache.lastFetchedAt,
|
|
478
|
+
})
|
|
479
|
+
.from(actorCache)
|
|
480
|
+
.where(eq(actorCache.apId, msg.recipientActorApId))
|
|
481
|
+
.get();
|
|
482
|
+
const lastFetchedMs = safeParseIsoTimeMs(cached?.lastFetchedAt ?? null);
|
|
483
|
+
const stale =
|
|
484
|
+
lastFetchedMs === null ||
|
|
485
|
+
Date.now() - lastFetchedMs > DELIVERY_ENDPOINT_CACHE_TTL_MS;
|
|
486
|
+
if (!cached || stale) {
|
|
487
|
+
try {
|
|
488
|
+
await fetchAndCacheRemoteActor(db, msg.recipientActorApId);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// Bound the retry: a permanently-unresolvable recipient (dead host,
|
|
491
|
+
// persistent 5xx, SSRF-blocked) must NOT re-enqueue a fresh resolve_actor
|
|
492
|
+
// every 60s forever. Give up after MAX_RESOLVE_ATTEMPTS so the activity is
|
|
493
|
+
// dropped for that recipient instead of churning the queue indefinitely.
|
|
494
|
+
const nextAttempt = (msg.attempts ?? 0) + 1;
|
|
495
|
+
if (nextAttempt > MAX_RESOLVE_ATTEMPTS) {
|
|
496
|
+
log.warn("resolve_actor giving up after max attempts", {
|
|
497
|
+
event: "delivery.resolve_actor.exhausted",
|
|
498
|
+
actor: msg.recipientActorApId,
|
|
499
|
+
activityId: msg.activityId,
|
|
500
|
+
attempts: nextAttempt,
|
|
501
|
+
error: e,
|
|
502
|
+
});
|
|
503
|
+
message.ack();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
log.warn("resolve_actor fetch failed", {
|
|
507
|
+
event: "delivery.resolve_actor.failed",
|
|
508
|
+
actor: msg.recipientActorApId,
|
|
509
|
+
activityId: msg.activityId,
|
|
510
|
+
attempt: nextAttempt,
|
|
511
|
+
error: e,
|
|
512
|
+
});
|
|
513
|
+
await sendQueueMessage(
|
|
514
|
+
env,
|
|
515
|
+
buildResolveActorMessage(
|
|
516
|
+
msg.activityId,
|
|
517
|
+
msg.recipientActorApId,
|
|
518
|
+
nextAttempt,
|
|
519
|
+
),
|
|
520
|
+
60,
|
|
521
|
+
);
|
|
522
|
+
message.ack();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const row = await db
|
|
528
|
+
.select({
|
|
529
|
+
inbox: actorCache.inbox,
|
|
530
|
+
sharedInbox: actorCache.sharedInbox,
|
|
531
|
+
})
|
|
532
|
+
.from(actorCache)
|
|
533
|
+
.where(eq(actorCache.apId, msg.recipientActorApId))
|
|
534
|
+
.get();
|
|
535
|
+
const endpoint = resolvePreferredEndpoint(row ?? null);
|
|
536
|
+
|
|
537
|
+
if (!endpoint) {
|
|
538
|
+
log.warn("Could not resolve endpoint for actor", {
|
|
539
|
+
event: "delivery.endpoint.unresolved",
|
|
540
|
+
actor: msg.recipientActorApId,
|
|
541
|
+
activityId: msg.activityId,
|
|
542
|
+
});
|
|
543
|
+
message.ack();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const jobId = await computeDeliveryJobId(msg.activityId, endpoint);
|
|
548
|
+
await upsertDeliveryJob(db, jobId, msg.activityId, endpoint);
|
|
549
|
+
await sendQueueMessage(env, buildDeliverEndpointMessage(jobId));
|
|
550
|
+
message.ack();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function processReconcileJob(
|
|
554
|
+
db: Database,
|
|
555
|
+
env: Env,
|
|
556
|
+
msg: DeliveryReconcileJobMessageV1,
|
|
557
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
558
|
+
): Promise<void> {
|
|
559
|
+
if (!requireQueue(env, "reconcile", message)) return;
|
|
560
|
+
|
|
561
|
+
if (msg.reconcileAttempt > MAX_RECONCILE_ATTEMPTS) {
|
|
562
|
+
message.ack();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const job = await db
|
|
567
|
+
.select({
|
|
568
|
+
id: deliveryQueue.id,
|
|
569
|
+
status: deliveryQueue.status,
|
|
570
|
+
})
|
|
571
|
+
.from(deliveryQueue)
|
|
572
|
+
.where(eq(deliveryQueue.id, msg.jobId))
|
|
573
|
+
.get();
|
|
574
|
+
|
|
575
|
+
if (!job || job.status === "delivered") {
|
|
576
|
+
message.ack();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
await db
|
|
581
|
+
.update(deliveryQueue)
|
|
582
|
+
.set({
|
|
583
|
+
status: "pending",
|
|
584
|
+
// Reset the attempt budget: a reconciled (dead-lettered) job still carries
|
|
585
|
+
// attempts at its max, so without this its first retryable failure would
|
|
586
|
+
// immediately re-dead-letter it with no real retry budget.
|
|
587
|
+
attempts: 0,
|
|
588
|
+
error: null,
|
|
589
|
+
lastAttemptAt: null,
|
|
590
|
+
processingStartedAt: null,
|
|
591
|
+
nextAttemptAt: nowIso(),
|
|
592
|
+
})
|
|
593
|
+
.where(eq(deliveryQueue.id, msg.jobId));
|
|
594
|
+
|
|
595
|
+
// Carry the reconcile-cycle count forward on the revived delivery so that, if
|
|
596
|
+
// it dead-letters again, the next DLQ message advances the budget (and the
|
|
597
|
+
// loop terminates after MAX_RECONCILE_ATTEMPTS) instead of resetting to 1.
|
|
598
|
+
await sendQueueMessage(
|
|
599
|
+
env,
|
|
600
|
+
buildDeliverEndpointMessage(msg.jobId, msg.reconcileAttempt),
|
|
601
|
+
);
|
|
602
|
+
message.ack();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function runWithConcurrency<T>(
|
|
606
|
+
items: T[],
|
|
607
|
+
concurrency: number,
|
|
608
|
+
fn: (item: T) => Promise<void>,
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const queue = items.slice();
|
|
611
|
+
const workers: Promise<void>[] = [];
|
|
612
|
+
|
|
613
|
+
for (let i = 0; i < concurrency; i++) {
|
|
614
|
+
workers.push(
|
|
615
|
+
(async () => {
|
|
616
|
+
while (queue.length > 0) {
|
|
617
|
+
const item = queue.shift();
|
|
618
|
+
if (!item) break;
|
|
619
|
+
await fn(item);
|
|
620
|
+
}
|
|
621
|
+
})(),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
await Promise.all(workers);
|
|
626
|
+
}
|