@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,1191 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import type { Env, Variables } from "../../types.ts";
|
|
4
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
5
|
+
import { activities, actorCache, actors, follows } from "../../../db/index.ts";
|
|
6
|
+
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
7
|
+
import {
|
|
8
|
+
activityApId,
|
|
9
|
+
actorApId,
|
|
10
|
+
isLocal,
|
|
11
|
+
isSafeRemoteUrl,
|
|
12
|
+
} from "../../federation-helpers.ts";
|
|
13
|
+
import { sha256Hex } from "../../lib/delivery/transformers.ts";
|
|
14
|
+
import { getInstanceActor, loadFederatedCommunity } from "./query-helpers.ts";
|
|
15
|
+
import { communityApId, getDomain } from "../../lib/ap-ids.ts";
|
|
16
|
+
import type { Activity } from "./inbox-types.ts";
|
|
17
|
+
import {
|
|
18
|
+
getActivityObject,
|
|
19
|
+
getActivityObjectId,
|
|
20
|
+
typeIncludes,
|
|
21
|
+
} from "./inbox-types.ts";
|
|
22
|
+
import { findFollowByActivityId } from "./handlers/inbox-shared-helpers.ts";
|
|
23
|
+
import {
|
|
24
|
+
ActivityPubContractError,
|
|
25
|
+
parseActivity,
|
|
26
|
+
} from "../../lib/activitypub-validators.ts";
|
|
27
|
+
import {
|
|
28
|
+
fetchAndUpsertActorCache,
|
|
29
|
+
getInstanceFetchSignerByDb,
|
|
30
|
+
} from "../../lib/activitypub-actor-cache.ts";
|
|
31
|
+
import { logger } from "../../lib/logger.ts";
|
|
32
|
+
import { verifyHttpSignature } from "../../lib/ap-verify.ts";
|
|
33
|
+
import { isActorBlocked, isDomainBlocked } from "../../lib/blocklist.ts";
|
|
34
|
+
import {
|
|
35
|
+
consumeRateLimitProgrammatic,
|
|
36
|
+
RateLimitConfigs,
|
|
37
|
+
} from "../../middleware/rate-limit.ts";
|
|
38
|
+
import {
|
|
39
|
+
handleGroupCreate,
|
|
40
|
+
handleGroupFollow,
|
|
41
|
+
handleGroupUndo,
|
|
42
|
+
} from "./handlers/actor-inbox-handlers.ts";
|
|
43
|
+
import {
|
|
44
|
+
handleAccept,
|
|
45
|
+
handleAdd,
|
|
46
|
+
handleAnnounce,
|
|
47
|
+
handleBlock,
|
|
48
|
+
handleCreate,
|
|
49
|
+
handleDelete,
|
|
50
|
+
handleFlag,
|
|
51
|
+
handleFollow,
|
|
52
|
+
handleLike,
|
|
53
|
+
handleMove,
|
|
54
|
+
handleReject,
|
|
55
|
+
handleRemove,
|
|
56
|
+
handleUndo,
|
|
57
|
+
handleUpdate,
|
|
58
|
+
} from "./handlers/user-inbox-handlers.ts";
|
|
59
|
+
|
|
60
|
+
const log = logger.child({ component: "activitypub.inbox" });
|
|
61
|
+
|
|
62
|
+
type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
|
|
63
|
+
|
|
64
|
+
const MAX_PAYLOAD_BYTES = 512 * 1024;
|
|
65
|
+
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
66
|
+
|
|
67
|
+
type RequestBodyResult =
|
|
68
|
+
{ ok: true; body: string } | { ok: false; status: 400 | 413; error: string };
|
|
69
|
+
|
|
70
|
+
async function readRequestBodyWithLimit(
|
|
71
|
+
request: Request,
|
|
72
|
+
maxBytes: number,
|
|
73
|
+
): Promise<RequestBodyResult> {
|
|
74
|
+
const reader = request.body?.getReader();
|
|
75
|
+
if (!reader) return { ok: true, body: "" };
|
|
76
|
+
|
|
77
|
+
const chunks: Uint8Array[] = [];
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) break;
|
|
83
|
+
if (!value) continue;
|
|
84
|
+
|
|
85
|
+
totalBytes += value.byteLength;
|
|
86
|
+
if (totalBytes > maxBytes) {
|
|
87
|
+
await reader.cancel();
|
|
88
|
+
return { ok: false, status: 413, error: "Payload too large" };
|
|
89
|
+
}
|
|
90
|
+
chunks.push(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const bodyBytes = new Uint8Array(totalBytes);
|
|
94
|
+
let offset = 0;
|
|
95
|
+
for (const chunk of chunks) {
|
|
96
|
+
bodyBytes.set(chunk, offset);
|
|
97
|
+
offset += chunk.byteLength;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
return { ok: true, body: TEXT_DECODER.decode(bodyBytes) };
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, status: 400, error: "Invalid UTF-8 body" };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Shared inbox helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract the actor URL from a keyId (strips the fragment, e.g. "#main-key").
|
|
113
|
+
*/
|
|
114
|
+
export function signingActorFromKeyId(
|
|
115
|
+
keyId: string | undefined,
|
|
116
|
+
): string | undefined {
|
|
117
|
+
if (!keyId) return undefined;
|
|
118
|
+
return keyId.includes("#") ? keyId.split("#")[0] : keyId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize an actor URL for identity comparison: lowercase the host (host names
|
|
123
|
+
* are case-insensitive) and drop a single trailing slash + any fragment, leaving
|
|
124
|
+
* the (case-sensitive) path intact. Used to compare the signing-key owner with
|
|
125
|
+
* the activity actor without rejecting cosmetically-different-but-identical IRIs
|
|
126
|
+
* (trailing slash / host case) that conformant peers occasionally emit. Returns
|
|
127
|
+
* null for an unparseable URL.
|
|
128
|
+
*/
|
|
129
|
+
function normalizeActorUrl(url: string): string | null {
|
|
130
|
+
try {
|
|
131
|
+
const u = new URL(url);
|
|
132
|
+
u.hash = "";
|
|
133
|
+
let normalized = `${u.protocol}//${u.host}${u.pathname}${u.search}`;
|
|
134
|
+
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
135
|
+
return normalized;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns true when the HTTP-signature signing key does NOT belong to exactly
|
|
143
|
+
* the activity actor (after URL normalization).
|
|
144
|
+
*
|
|
145
|
+
* SECURITY (#1 — same-host key-delegation impersonation): the signature is
|
|
146
|
+
* verified against the key published by the keyId's OWNER, and every per-type
|
|
147
|
+
* handler then authorizes purely on the activity `actor` string. So this binding
|
|
148
|
+
* is the ONLY thing tying the verified key to the claimed actor — it must be an
|
|
149
|
+
* EXACT identity match. An earlier version accepted any signer sharing the same
|
|
150
|
+
* URL host as the actor ("domain-level key delegation"). On a multi-user remote
|
|
151
|
+
* host that let an attacker who controls one key (`alice#main-key`) sign an
|
|
152
|
+
* activity claiming `actor=victim` on the same host and have it accepted AS the
|
|
153
|
+
* victim — cross-actor impersonation (forged Delete, DM-as-victim, Move-based
|
|
154
|
+
* follower theft, etc.). Mastodon/Lemmy bind keyId-owner === activity.actor
|
|
155
|
+
* exactly, so this matches the fediverse norm.
|
|
156
|
+
*
|
|
157
|
+
* The only legitimate cross-actor case is a remote instance/server actor (type
|
|
158
|
+
* Application/Service) signing a FORWARDED activity on behalf of one of its
|
|
159
|
+
* users (ActivityPub §7.1.2 inbox forwarding). That is safe ONLY when the
|
|
160
|
+
* forwarded object's integrity is independently re-verified (an LD-signature on
|
|
161
|
+
* the object, or re-fetching it from its origin). This codebase performs no such
|
|
162
|
+
* re-verification anywhere, so we reject cross-actor delegation outright rather
|
|
163
|
+
* than trust an unverified relayed envelope.
|
|
164
|
+
*/
|
|
165
|
+
export function isActorMismatch(
|
|
166
|
+
signingActorUrl: string | undefined,
|
|
167
|
+
actor: string,
|
|
168
|
+
): boolean {
|
|
169
|
+
if (!signingActorUrl) return true;
|
|
170
|
+
if (signingActorUrl === actor) return false;
|
|
171
|
+
|
|
172
|
+
const normalizedSigner = normalizeActorUrl(signingActorUrl);
|
|
173
|
+
const normalizedActor = normalizeActorUrl(actor);
|
|
174
|
+
if (
|
|
175
|
+
normalizedSigner !== null &&
|
|
176
|
+
normalizedActor !== null &&
|
|
177
|
+
normalizedSigner === normalizedActor
|
|
178
|
+
) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type ParsedActivity = {
|
|
185
|
+
activity: Activity;
|
|
186
|
+
activityId: string;
|
|
187
|
+
actor: string;
|
|
188
|
+
activityType: string;
|
|
189
|
+
activityObjectId: string | null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Shared pipeline for both inbox endpoints: size check, signature verification,
|
|
194
|
+
* JSON parse, field extraction, and actor-mismatch check. Returns either a
|
|
195
|
+
* parsed result or a Response that should be returned immediately.
|
|
196
|
+
*/
|
|
197
|
+
async function verifyAndParseInbox(
|
|
198
|
+
c: HonoContext,
|
|
199
|
+
baseUrl: string,
|
|
200
|
+
): Promise<ParsedActivity | Response> {
|
|
201
|
+
const contentLengthHeader = c.req.header("content-length");
|
|
202
|
+
if (contentLengthHeader) {
|
|
203
|
+
const contentLength = Number(contentLengthHeader);
|
|
204
|
+
if (!Number.isInteger(contentLength) || contentLength < 0) {
|
|
205
|
+
return c.json({ error: "Invalid Content-Length" }, 400);
|
|
206
|
+
}
|
|
207
|
+
if (contentLength > MAX_PAYLOAD_BYTES) {
|
|
208
|
+
return c.json({ error: "Payload too large" }, 413);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const bodyResult = await readRequestBodyWithLimit(
|
|
213
|
+
c.req.raw,
|
|
214
|
+
MAX_PAYLOAD_BYTES,
|
|
215
|
+
);
|
|
216
|
+
if (!bodyResult.ok) {
|
|
217
|
+
return c.json({ error: bodyResult.error }, bodyResult.status);
|
|
218
|
+
}
|
|
219
|
+
const body = bodyResult.body;
|
|
220
|
+
|
|
221
|
+
const signatureResult = await verifyHttpSignature(
|
|
222
|
+
c.req.raw,
|
|
223
|
+
c.get("db"),
|
|
224
|
+
body,
|
|
225
|
+
);
|
|
226
|
+
if (!signatureResult.valid) {
|
|
227
|
+
log.warn("Signature verification failed", {
|
|
228
|
+
event: "ap.signature.verification_failed",
|
|
229
|
+
reason: signatureResult.error,
|
|
230
|
+
});
|
|
231
|
+
return c.json({ error: "Signature verification failed" }, 401);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let activity: Activity;
|
|
235
|
+
try {
|
|
236
|
+
const parsed: unknown = JSON.parse(body);
|
|
237
|
+
activity = parseActivity(parsed);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e instanceof ActivityPubContractError) {
|
|
240
|
+
log.warn("Rejected activity (contract error)", {
|
|
241
|
+
event: "ap.activity.contract_rejected",
|
|
242
|
+
reason: e.message,
|
|
243
|
+
});
|
|
244
|
+
return c.json({ error: "Invalid activity" }, 400);
|
|
245
|
+
}
|
|
246
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const actor = typeof activity.actor === "string" ? activity.actor : null;
|
|
250
|
+
const activityType = typeof activity.type === "string" ? activity.type : null;
|
|
251
|
+
|
|
252
|
+
if (!actor || !activityType) {
|
|
253
|
+
return c.json({ error: "Invalid activity" }, 400);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The activity envelope `id` becomes the dedup ledger key (activities.apId).
|
|
257
|
+
// Only trust a remote-supplied id when it shares the (signature-bound) actor's
|
|
258
|
+
// origin and is not a local id; otherwise a remote could set `id` to ANOTHER
|
|
259
|
+
// instance's namespace and pre-occupy that row (processed=1), silently
|
|
260
|
+
// black-holing that instance's later legitimate redelivery of the same id
|
|
261
|
+
// (denial of federation / dedup-ledger poisoning). Mirrors the object-id
|
|
262
|
+
// origin guard (isObjectIdOriginMismatch) but for the envelope used by dedup.
|
|
263
|
+
// An untrustworthy id does NOT drop the (validly-signed) activity — it just
|
|
264
|
+
// gets deduped under a local deterministic synthetic id instead.
|
|
265
|
+
const rawActivityId = typeof activity.id === "string" ? activity.id : null;
|
|
266
|
+
let activityIdTrusted = false;
|
|
267
|
+
if (rawActivityId !== null && !isLocal(rawActivityId, baseUrl)) {
|
|
268
|
+
try {
|
|
269
|
+
activityIdTrusted = getDomain(rawActivityId) === getDomain(actor);
|
|
270
|
+
} catch {
|
|
271
|
+
activityIdTrusted = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const activityId =
|
|
275
|
+
rawActivityId !== null && activityIdTrusted
|
|
276
|
+
? rawActivityId
|
|
277
|
+
: activityApId(
|
|
278
|
+
baseUrl,
|
|
279
|
+
// Deterministic synthetic id (local namespace) for an id-less OR
|
|
280
|
+
// untrusted-origin activity: a redelivery of the SAME logical action
|
|
281
|
+
// then dedups via the activities table instead of minting a fresh
|
|
282
|
+
// RANDOM id each time (which re-processed it on every retry).
|
|
283
|
+
// Defense-in-depth — the side-effect handlers are also independently
|
|
284
|
+
// idempotent.
|
|
285
|
+
`synthetic-${await sha256Hex(
|
|
286
|
+
`${actor}|${activityType}|${getActivityObjectId(activity) ?? ""}`,
|
|
287
|
+
)}`,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Id-less activity (#8): stamp the deterministic synthetic id onto the envelope
|
|
291
|
+
// so the per-type handlers — which derive their notification / activities id
|
|
292
|
+
// from `activity.id` (`activity.id || activityApId(generateId())`) — converge
|
|
293
|
+
// on the SAME id across a concurrent dual-endpoint delivery or an in-flight
|
|
294
|
+
// re-claim, instead of each run minting a fresh RANDOM id and inserting
|
|
295
|
+
// DUPLICATE inbox/notification rows. A present (trusted OR untrusted) id is a
|
|
296
|
+
// stable string that already dedups, so only the absent-id case needs this.
|
|
297
|
+
if (rawActivityId === null) {
|
|
298
|
+
activity.id = activityId;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const signingActor = signingActorFromKeyId(signatureResult.keyId);
|
|
302
|
+
if (isActorMismatch(signingActor, actor)) {
|
|
303
|
+
log.warn("Actor mismatch between activity and signing key", {
|
|
304
|
+
event: "ap.signature.actor_mismatch",
|
|
305
|
+
actor,
|
|
306
|
+
signingActor,
|
|
307
|
+
keyId: signatureResult.keyId,
|
|
308
|
+
});
|
|
309
|
+
return c.json({ error: "Actor mismatch" }, 401);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Central federation blocklist gate. Applied once here so every activity
|
|
313
|
+
// type (Follow / Like / Announce / Undo / content / group inbox / ...) is
|
|
314
|
+
// covered regardless of which handler dispatches it. Blocked traffic is
|
|
315
|
+
// silently discarded with a 202 ACK (never 4xx) — a 4xx would make the
|
|
316
|
+
// sending instance retry on a backoff and keep redelivering blocked
|
|
317
|
+
// traffic. The blocklist helpers fail open on a DB read error (see
|
|
318
|
+
// lib/blocklist.ts), so a transient DB fault never black-holes federation.
|
|
319
|
+
if (await isActivityBlocked(c, actor, activityType)) {
|
|
320
|
+
return c.body(null, 202);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
activity,
|
|
325
|
+
activityId,
|
|
326
|
+
actor,
|
|
327
|
+
activityType,
|
|
328
|
+
activityObjectId: getActivityObjectId(activity),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Return `true` when an inbound activity must be silently discarded because
|
|
334
|
+
* the sending actor (or its domain) is on the operator blocklist. Callers
|
|
335
|
+
* should 202-discard rather than 4xx — federation peers retry 4xx responses
|
|
336
|
+
* on a backoff and would otherwise keep redelivering blocked traffic.
|
|
337
|
+
*/
|
|
338
|
+
async function isActivityBlocked(
|
|
339
|
+
c: HonoContext,
|
|
340
|
+
actor: string,
|
|
341
|
+
activityType: string,
|
|
342
|
+
): Promise<boolean> {
|
|
343
|
+
const db = c.get("db");
|
|
344
|
+
|
|
345
|
+
if (await isActorBlocked(db, actor)) {
|
|
346
|
+
log.info("Discarding activity from blocked actor", {
|
|
347
|
+
event: "ap.blocklist.actor_discard",
|
|
348
|
+
actor,
|
|
349
|
+
activityType,
|
|
350
|
+
});
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let hostname: string | null = null;
|
|
355
|
+
try {
|
|
356
|
+
hostname = new URL(actor).hostname;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (await isDomainBlocked(db, hostname)) {
|
|
362
|
+
log.info("Discarding activity from blocked domain", {
|
|
363
|
+
event: "ap.blocklist.domain_discard",
|
|
364
|
+
actor,
|
|
365
|
+
domain: hostname,
|
|
366
|
+
activityType,
|
|
367
|
+
});
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// `processed` ledger values for an inbound activity row:
|
|
375
|
+
// 0 = stored, dispatch not yet committed (newly inserted, or a prior dispatch
|
|
376
|
+
// threw — such a row is RE-DISPATCHABLE so a peer retry completes it)
|
|
377
|
+
// 1 = dispatch effects committed successfully (terminal; suppresses re-dispatch)
|
|
378
|
+
const PROCESSED_UNPROCESSED = 0;
|
|
379
|
+
const PROCESSED_DONE = 1;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A request that owns dispatch for an inbound activity. After running the
|
|
383
|
+
* handler the caller MUST call `commitActivityDispatch` on success so a
|
|
384
|
+
* subsequent (re)delivery is suppressed. On handler failure the caller does
|
|
385
|
+
* nothing extra: the row stays `processed = 0`, so a peer retry re-dispatches
|
|
386
|
+
* and completes the effect instead of being permanently black-holed by the
|
|
387
|
+
* dedup row.
|
|
388
|
+
*/
|
|
389
|
+
type ActivityDispatchClaim = {
|
|
390
|
+
activityId: string;
|
|
391
|
+
activityType: string;
|
|
392
|
+
actor: string;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Dedup + claim. Stores the inbound activity (idempotent on the `apId` primary
|
|
397
|
+
* key) and decides whether THIS request must dispatch. Returns either:
|
|
398
|
+
* - a `Response` (202) when the activity must NOT be dispatched (a concurrent
|
|
399
|
+
* delivery already created the row, or a prior delivery already committed
|
|
400
|
+
* `processed = 1`); or
|
|
401
|
+
* - an `ActivityDispatchClaim` when this request owns dispatch.
|
|
402
|
+
*
|
|
403
|
+
* Idempotency model: `apId` is the primary key, so `onConflictDoNothing` makes
|
|
404
|
+
* the dedup insert atomic — exactly one concurrent delivery of the same
|
|
405
|
+
* activity creates the row (and gets a non-null returned row → owns dispatch);
|
|
406
|
+
* the rest get a null row. This is what keeps a genuine concurrent double
|
|
407
|
+
* delivery (shared inbox + per-actor inbox racing) from applying the effect
|
|
408
|
+
* twice or 500'ing on a PK violation.
|
|
409
|
+
*
|
|
410
|
+
* Retry-after-failure fix (#9): the dedup row is no longer unconditionally
|
|
411
|
+
* suppressing. When this request LOST the insert (row already exists), we only
|
|
412
|
+
* suppress if that row is already `processed = 1` (a committed prior dispatch).
|
|
413
|
+
* If the existing row is still `processed = 0` — i.e. a prior dispatch threw
|
|
414
|
+
* mid-effect and never committed — we re-claim it so the peer's retry completes
|
|
415
|
+
* the half-applied activity exactly once (the commit flips it to `1`, after
|
|
416
|
+
* which any further redelivery is suppressed).
|
|
417
|
+
*/
|
|
418
|
+
async function claimActivityForDispatch(
|
|
419
|
+
c: HonoContext,
|
|
420
|
+
{
|
|
421
|
+
activityId,
|
|
422
|
+
activityType,
|
|
423
|
+
actor,
|
|
424
|
+
activityObjectId,
|
|
425
|
+
activity,
|
|
426
|
+
}: ParsedActivity,
|
|
427
|
+
): Promise<Response | ActivityDispatchClaim> {
|
|
428
|
+
const db = c.get("db");
|
|
429
|
+
const rawJson = JSON.stringify(activity);
|
|
430
|
+
|
|
431
|
+
// Atomic insert-or-skip. A non-null returned row means THIS request created
|
|
432
|
+
// the dedup row and owns dispatch; a null row means a concurrent or prior
|
|
433
|
+
// delivery already stored it.
|
|
434
|
+
const inserted = await db
|
|
435
|
+
.insert(activities)
|
|
436
|
+
.values({
|
|
437
|
+
apId: activityId,
|
|
438
|
+
type: activityType,
|
|
439
|
+
actorApId: actor,
|
|
440
|
+
objectApId: activityObjectId,
|
|
441
|
+
rawJson,
|
|
442
|
+
direction: "inbound",
|
|
443
|
+
processed: PROCESSED_UNPROCESSED,
|
|
444
|
+
})
|
|
445
|
+
.onConflictDoNothing()
|
|
446
|
+
.returning()
|
|
447
|
+
.get();
|
|
448
|
+
|
|
449
|
+
if (inserted) {
|
|
450
|
+
return { activityId, activityType, actor };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Lost the insert: a row already exists. Suppress ONLY if it was already
|
|
454
|
+
// dispatched to completion (`processed = 1`). An existing `processed = 0` row
|
|
455
|
+
// means a prior dispatch threw without committing, so this redelivery must
|
|
456
|
+
// re-dispatch to finish it — otherwise the dedup row would permanently
|
|
457
|
+
// suppress re-dispatch of a half-applied activity (bug #9).
|
|
458
|
+
const existing = await db.query.activities.findFirst({
|
|
459
|
+
where: eq(activities.apId, activityId),
|
|
460
|
+
columns: { processed: true },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (existing && existing.processed === PROCESSED_UNPROCESSED) {
|
|
464
|
+
log.info("Re-dispatching previously-uncommitted activity", {
|
|
465
|
+
event: "ap.activity.redispatch_uncommitted",
|
|
466
|
+
activityId,
|
|
467
|
+
activityType,
|
|
468
|
+
actor,
|
|
469
|
+
});
|
|
470
|
+
return { activityId, activityType, actor };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
log.info("Duplicate activity skipped", {
|
|
474
|
+
event: "ap.activity.duplicate_skipped",
|
|
475
|
+
activityId,
|
|
476
|
+
activityType,
|
|
477
|
+
actor,
|
|
478
|
+
});
|
|
479
|
+
return c.body(null, 202);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Mark a claimed activity's dispatch as terminally complete. Called after the
|
|
484
|
+
* handler effects commit successfully so any subsequent (re)delivery is
|
|
485
|
+
* suppressed by `claimActivityForDispatch`.
|
|
486
|
+
*/
|
|
487
|
+
async function commitActivityDispatch(
|
|
488
|
+
c: HonoContext,
|
|
489
|
+
activityId: string,
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
const db = c.get("db");
|
|
492
|
+
await db
|
|
493
|
+
.update(activities)
|
|
494
|
+
.set({ processed: PROCESSED_DONE })
|
|
495
|
+
.where(eq(activities.apId, activityId));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Remote actor caching
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
async function cacheRemoteActor(
|
|
503
|
+
c: HonoContext,
|
|
504
|
+
actorApIdUrl: string,
|
|
505
|
+
baseUrl: string,
|
|
506
|
+
): Promise<void> {
|
|
507
|
+
if (isLocal(actorApIdUrl, baseUrl)) return;
|
|
508
|
+
|
|
509
|
+
const db = c.get("db");
|
|
510
|
+
|
|
511
|
+
const cached = await db.query.actorCache.findFirst({
|
|
512
|
+
where: eq(actorCache.apId, actorApIdUrl),
|
|
513
|
+
columns: { apId: true },
|
|
514
|
+
});
|
|
515
|
+
if (cached) return;
|
|
516
|
+
|
|
517
|
+
if (!isSafeRemoteUrl(actorApIdUrl)) {
|
|
518
|
+
log.warn("Blocked unsafe actor fetch", {
|
|
519
|
+
event: "ap.actor.unsafe_fetch_blocked",
|
|
520
|
+
actor: actorApIdUrl,
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// `mode: "insert"` keeps this cache-when-absent and race-safe: the early
|
|
526
|
+
// `cached` check above is best-effort only, so two isolates racing the same
|
|
527
|
+
// cold actor can both reach the insert, and `onConflictDoNothing` avoids a
|
|
528
|
+
// spurious primary-key-violation error.
|
|
529
|
+
const result = await fetchAndUpsertActorCache(db, actorApIdUrl, {
|
|
530
|
+
timeout: 15000,
|
|
531
|
+
mode: "insert",
|
|
532
|
+
publicKey: "require-key",
|
|
533
|
+
// Sign as the instance actor so a secure-mode remote serves its doc.
|
|
534
|
+
signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
535
|
+
});
|
|
536
|
+
if (result.ok) return;
|
|
537
|
+
|
|
538
|
+
switch (result.reason) {
|
|
539
|
+
case "invalid_document":
|
|
540
|
+
log.warn("Skipping actor cache: invalid actor document", {
|
|
541
|
+
event: "ap.actor.cache_invalid_document",
|
|
542
|
+
actor: actorApIdUrl,
|
|
543
|
+
});
|
|
544
|
+
break;
|
|
545
|
+
case "id_mismatch":
|
|
546
|
+
log.warn("Actor ID mismatch during cache", {
|
|
547
|
+
event: "ap.actor.cache_id_mismatch",
|
|
548
|
+
actor: actorApIdUrl,
|
|
549
|
+
});
|
|
550
|
+
break;
|
|
551
|
+
case "missing_public_key":
|
|
552
|
+
log.warn("Skipping actor cache: missing public key", {
|
|
553
|
+
event: "ap.actor.cache_missing_public_key",
|
|
554
|
+
actor: actorApIdUrl,
|
|
555
|
+
});
|
|
556
|
+
break;
|
|
557
|
+
case "fetch_failed":
|
|
558
|
+
log.error("Failed to cache remote actor", {
|
|
559
|
+
event: "ap.actor.cache_failed",
|
|
560
|
+
actor: actorApIdUrl,
|
|
561
|
+
});
|
|
562
|
+
break;
|
|
563
|
+
// `fetch_not_ok` and `missing_inbox` were silently skipped before.
|
|
564
|
+
default:
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// User inbox activity dispatch
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
/** The Drizzle row type for actors table */
|
|
574
|
+
type ActorRow = typeof actors.$inferSelect;
|
|
575
|
+
|
|
576
|
+
type UserInboxHandler = {
|
|
577
|
+
recipient: ActorRow;
|
|
578
|
+
actor: string;
|
|
579
|
+
baseUrl: string;
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
async function dispatchUserActivity(
|
|
583
|
+
c: HonoContext,
|
|
584
|
+
activityType: string,
|
|
585
|
+
activity: Activity,
|
|
586
|
+
{ recipient, actor, baseUrl }: UserInboxHandler,
|
|
587
|
+
): Promise<void> {
|
|
588
|
+
switch (activityType) {
|
|
589
|
+
case "Follow":
|
|
590
|
+
await handleFollow(c, activity, recipient, actor, baseUrl);
|
|
591
|
+
break;
|
|
592
|
+
case "Accept":
|
|
593
|
+
await handleAccept(c, activity, actor);
|
|
594
|
+
break;
|
|
595
|
+
case "Undo":
|
|
596
|
+
await handleUndo(c, activity, recipient, actor, baseUrl);
|
|
597
|
+
break;
|
|
598
|
+
case "Like":
|
|
599
|
+
await handleLike(c, activity, recipient, actor, baseUrl);
|
|
600
|
+
break;
|
|
601
|
+
case "Create":
|
|
602
|
+
await handleCreate(c, activity, recipient, actor, baseUrl);
|
|
603
|
+
break;
|
|
604
|
+
case "Delete":
|
|
605
|
+
await handleDelete(c, activity);
|
|
606
|
+
break;
|
|
607
|
+
case "Announce":
|
|
608
|
+
await handleAnnounce(c, activity, recipient, actor, baseUrl);
|
|
609
|
+
break;
|
|
610
|
+
case "Update":
|
|
611
|
+
await handleUpdate(c, activity, actor);
|
|
612
|
+
break;
|
|
613
|
+
case "Reject":
|
|
614
|
+
await handleReject(c, activity, actor);
|
|
615
|
+
break;
|
|
616
|
+
case "Add":
|
|
617
|
+
await handleAdd(c, activity, recipient, actor);
|
|
618
|
+
break;
|
|
619
|
+
case "Remove":
|
|
620
|
+
await handleRemove(c, activity, recipient, actor);
|
|
621
|
+
break;
|
|
622
|
+
case "Block":
|
|
623
|
+
await handleBlock(c, activity, recipient, actor);
|
|
624
|
+
break;
|
|
625
|
+
case "Flag":
|
|
626
|
+
await handleFlag(c, activity, actor);
|
|
627
|
+
break;
|
|
628
|
+
case "Move":
|
|
629
|
+
await handleMove(c, activity, actor);
|
|
630
|
+
break;
|
|
631
|
+
default:
|
|
632
|
+
log.warn("Unhandled activity type", {
|
|
633
|
+
event: "ap.activity.unhandled_type",
|
|
634
|
+
activityType,
|
|
635
|
+
actor,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// Per-domain inbox throttling
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Apply a per-domain rate limit to an already-parsed inbox activity. This
|
|
646
|
+
* runs after signature verification so the bucket key is derived from the
|
|
647
|
+
* authenticated actor hostname rather than a spoofable header. Returns
|
|
648
|
+
* a 429 Response when the domain budget is exhausted.
|
|
649
|
+
*/
|
|
650
|
+
async function applyInboxDomainRateLimit(
|
|
651
|
+
c: HonoContext,
|
|
652
|
+
actor: string,
|
|
653
|
+
): Promise<Response | null> {
|
|
654
|
+
let domain: string;
|
|
655
|
+
try {
|
|
656
|
+
domain = new URL(actor).hostname.toLowerCase();
|
|
657
|
+
} catch {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
if (!domain) return null;
|
|
661
|
+
|
|
662
|
+
const { entry, limited, retryAfter } = await consumeRateLimitProgrammatic(
|
|
663
|
+
c.env.KV,
|
|
664
|
+
RateLimitConfigs.inboxDomain,
|
|
665
|
+
domain,
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
c.header(
|
|
669
|
+
"X-RateLimit-Domain-Limit",
|
|
670
|
+
RateLimitConfigs.inboxDomain.maxRequests.toString(),
|
|
671
|
+
);
|
|
672
|
+
c.header(
|
|
673
|
+
"X-RateLimit-Domain-Remaining",
|
|
674
|
+
Math.max(
|
|
675
|
+
0,
|
|
676
|
+
RateLimitConfigs.inboxDomain.maxRequests - entry.count,
|
|
677
|
+
).toString(),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
if (limited) {
|
|
681
|
+
log.warn("Per-domain inbox throttle exceeded", {
|
|
682
|
+
event: "ap.inbox.domain_rate_limited",
|
|
683
|
+
domain,
|
|
684
|
+
retryAfter,
|
|
685
|
+
});
|
|
686
|
+
c.header("Retry-After", retryAfter.toString());
|
|
687
|
+
return c.json(
|
|
688
|
+
{
|
|
689
|
+
error: "Too many requests from this domain",
|
|
690
|
+
retry_after: retryAfter,
|
|
691
|
+
},
|
|
692
|
+
429,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Routes
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
const ap = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
703
|
+
|
|
704
|
+
ap.post("/ap/actor/inbox", async (c) => {
|
|
705
|
+
const instActor = await getInstanceActor(c);
|
|
706
|
+
const baseUrl = c.env.APP_URL;
|
|
707
|
+
|
|
708
|
+
const result = await verifyAndParseInbox(c, baseUrl);
|
|
709
|
+
if (result instanceof Response) return result;
|
|
710
|
+
|
|
711
|
+
const throttled = await applyInboxDomainRateLimit(c, result.actor);
|
|
712
|
+
if (throttled) return throttled;
|
|
713
|
+
|
|
714
|
+
const claim = await claimActivityForDispatch(c, result);
|
|
715
|
+
if (claim instanceof Response) return claim;
|
|
716
|
+
|
|
717
|
+
const { activity, activityType, actor } = result;
|
|
718
|
+
|
|
719
|
+
// The activity row is stored (processed = 0) before group dispatch. A thrown
|
|
720
|
+
// handler is isolated and logged WITHOUT committing, so the row stays
|
|
721
|
+
// `processed = 0` and a peer retry re-dispatches to complete the effect rather
|
|
722
|
+
// than being permanently suppressed by the dedup row (#9). A successful
|
|
723
|
+
// dispatch commits (processed = 1) so retries are skipped. Either way we ACK
|
|
724
|
+
// 202 — a 5xx would make the remote retry, and a committed-too-early dedup row
|
|
725
|
+
// would black-hole the half-applied activity.
|
|
726
|
+
try {
|
|
727
|
+
switch (activityType) {
|
|
728
|
+
case "Follow":
|
|
729
|
+
await handleGroupFollow(
|
|
730
|
+
c,
|
|
731
|
+
activity,
|
|
732
|
+
instActor,
|
|
733
|
+
actor,
|
|
734
|
+
baseUrl,
|
|
735
|
+
result.activityId,
|
|
736
|
+
);
|
|
737
|
+
break;
|
|
738
|
+
case "Undo":
|
|
739
|
+
await handleGroupUndo(c, activity, instActor, actor);
|
|
740
|
+
break;
|
|
741
|
+
case "Create":
|
|
742
|
+
await handleGroupCreate(c, activity, instActor, actor, baseUrl);
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
746
|
+
} catch (e) {
|
|
747
|
+
log.error("Actor-inbox dispatch failed", {
|
|
748
|
+
event: "ap.actor_inbox.dispatch_error",
|
|
749
|
+
activityType,
|
|
750
|
+
actor,
|
|
751
|
+
error: e,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return c.body(null, 202);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Community (Group) inbox — a remote joins a community by POSTing a Follow
|
|
759
|
+
// here; we Accept (signed by the community key) per joinPolicy, and Undo
|
|
760
|
+
// removes the membership/follow. Mirrors the instance-actor inbox, reusing the
|
|
761
|
+
// shared Group handlers. Only PUBLIC communities are followable (the loader
|
|
762
|
+
// returns null for private/deleted → 404).
|
|
763
|
+
ap.post("/ap/groups/:name/inbox", async (c) => {
|
|
764
|
+
const db = c.get("db");
|
|
765
|
+
const baseUrl = c.env.APP_URL;
|
|
766
|
+
const name = c.req.param("name");
|
|
767
|
+
const community = await loadFederatedCommunity(
|
|
768
|
+
db,
|
|
769
|
+
communityApId(baseUrl.replace(/\/+$/, ""), name),
|
|
770
|
+
);
|
|
771
|
+
if (!community) return c.json({ error: "Community not found" }, 404);
|
|
772
|
+
|
|
773
|
+
const result = await verifyAndParseInbox(c, baseUrl);
|
|
774
|
+
if (result instanceof Response) return result;
|
|
775
|
+
|
|
776
|
+
const throttled = await applyInboxDomainRateLimit(c, result.actor);
|
|
777
|
+
if (throttled) return throttled;
|
|
778
|
+
|
|
779
|
+
const claim = await claimActivityForDispatch(c, result);
|
|
780
|
+
if (claim instanceof Response) return claim;
|
|
781
|
+
|
|
782
|
+
const { activity, activityType, actor } = result;
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
switch (activityType) {
|
|
786
|
+
case "Follow":
|
|
787
|
+
await handleGroupFollow(
|
|
788
|
+
c,
|
|
789
|
+
activity,
|
|
790
|
+
community,
|
|
791
|
+
actor,
|
|
792
|
+
baseUrl,
|
|
793
|
+
result.activityId,
|
|
794
|
+
);
|
|
795
|
+
break;
|
|
796
|
+
case "Undo":
|
|
797
|
+
await handleGroupUndo(c, activity, community, actor);
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
801
|
+
} catch (e) {
|
|
802
|
+
log.error("Community-inbox dispatch failed", {
|
|
803
|
+
event: "ap.community_inbox.dispatch_error",
|
|
804
|
+
activityType,
|
|
805
|
+
actor,
|
|
806
|
+
community: community.apId,
|
|
807
|
+
error: e,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return c.body(null, 202);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
ap.post("/ap/users/:username/inbox", async (c) => {
|
|
815
|
+
const db = c.get("db");
|
|
816
|
+
const username = c.req.param("username");
|
|
817
|
+
const baseUrl = c.env.APP_URL;
|
|
818
|
+
const apId = actorApId(baseUrl, username);
|
|
819
|
+
|
|
820
|
+
const recipient = await db.query.actors.findFirst({
|
|
821
|
+
where: eq(actors.apId, apId),
|
|
822
|
+
});
|
|
823
|
+
if (!recipient) return c.json({ error: "Actor not found" }, 404);
|
|
824
|
+
|
|
825
|
+
const result = await verifyAndParseInbox(c, baseUrl);
|
|
826
|
+
if (result instanceof Response) return result;
|
|
827
|
+
|
|
828
|
+
const throttled = await applyInboxDomainRateLimit(c, result.actor);
|
|
829
|
+
if (throttled) return throttled;
|
|
830
|
+
|
|
831
|
+
const claim = await claimActivityForDispatch(c, result);
|
|
832
|
+
if (claim instanceof Response) return claim;
|
|
833
|
+
|
|
834
|
+
const { activity, activityType, actor } = result;
|
|
835
|
+
|
|
836
|
+
await cacheRemoteActor(c, actor, baseUrl);
|
|
837
|
+
|
|
838
|
+
// The activity row is stored (processed = 0) before dispatch. If a handler
|
|
839
|
+
// throws we leave it uncommitted so a peer retry re-dispatches and completes
|
|
840
|
+
// the effect, instead of the dedup row permanently suppressing it (#9); on
|
|
841
|
+
// success we commit (processed = 1) so retries are skipped. We ACK 202
|
|
842
|
+
// regardless so a retry is not provoked into a 5xx loop.
|
|
843
|
+
try {
|
|
844
|
+
await dispatchUserActivity(c, activityType, activity, {
|
|
845
|
+
recipient,
|
|
846
|
+
actor,
|
|
847
|
+
baseUrl,
|
|
848
|
+
});
|
|
849
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
850
|
+
} catch (e) {
|
|
851
|
+
log.error("User-inbox dispatch failed", {
|
|
852
|
+
event: "ap.user_inbox.dispatch_error",
|
|
853
|
+
activityType,
|
|
854
|
+
actor,
|
|
855
|
+
recipient: recipient.apId,
|
|
856
|
+
error: e,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return c.body(null, 202);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
// Shared inbox (Mastodon convention)
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
//
|
|
867
|
+
// Both the user actor and the group/instance actor advertise
|
|
868
|
+
// `endpoints.sharedInbox = <baseUrl>/ap/inbox`. Mastodon and most large
|
|
869
|
+
// servers use sharedInbox as the PRIMARY fan-out delivery target, so a
|
|
870
|
+
// federated peer following a yurucommu user delivers Create/Like/Announce/
|
|
871
|
+
// Follow/Undo here. This endpoint runs the SAME verify/dedup/store pipeline as
|
|
872
|
+
// the per-actor inbox (`verifyAndParseInbox`) and then routes the activity to
|
|
873
|
+
// the appropriate local recipients, instead of black-holing it with a bare
|
|
874
|
+
// 202.
|
|
875
|
+
//
|
|
876
|
+
// Recipient resolution: the parsed activity envelope does not carry
|
|
877
|
+
// `to`/`cc`/`audience`, so for recipient-scoped activity types we fan out to
|
|
878
|
+
// every LOCAL actor that follows the activity actor (the standard sharedInbox
|
|
879
|
+
// semantic — the sending server delivers once and the receiving server
|
|
880
|
+
// distributes to its own followers). Recipient-independent types (Accept,
|
|
881
|
+
// Delete, Update, Reject, Flag, Move) are dispatched exactly once.
|
|
882
|
+
|
|
883
|
+
// Bound on the number of local followers fanned out per shared-inbox activity,
|
|
884
|
+
// so a single delivery cannot trigger an unbounded number of handler runs in
|
|
885
|
+
// one request. Local follower sets are small (this is a single-instance
|
|
886
|
+
// community app), so this ceiling is generous.
|
|
887
|
+
const MAX_SHARED_INBOX_FANOUT = 1000;
|
|
888
|
+
|
|
889
|
+
// Activity types whose handlers do not depend on the recipient actor; these
|
|
890
|
+
// are dispatched once rather than per local follower.
|
|
891
|
+
const RECIPIENT_INDEPENDENT_TYPES = new Set([
|
|
892
|
+
"Accept",
|
|
893
|
+
"Delete",
|
|
894
|
+
"Update",
|
|
895
|
+
"Reject",
|
|
896
|
+
"Flag",
|
|
897
|
+
"Move",
|
|
898
|
+
]);
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Resolve the local actor rows that follow `actorApIdValue` (an accepted
|
|
902
|
+
* follow), capped at MAX_SHARED_INBOX_FANOUT. Used to fan a shared-inbox
|
|
903
|
+
* activity out to the local recipients that subscribed to the sending actor.
|
|
904
|
+
*/
|
|
905
|
+
async function resolveLocalFollowerRecipients(
|
|
906
|
+
c: HonoContext,
|
|
907
|
+
actorApIdValue: string,
|
|
908
|
+
baseUrl: string,
|
|
909
|
+
): Promise<ActorRow[]> {
|
|
910
|
+
const db = c.get("db");
|
|
911
|
+
|
|
912
|
+
const followerRows = await db
|
|
913
|
+
.select({
|
|
914
|
+
followerApId: follows.followerApId,
|
|
915
|
+
})
|
|
916
|
+
.from(follows)
|
|
917
|
+
.where(
|
|
918
|
+
and(
|
|
919
|
+
eq(follows.followingApId, actorApIdValue),
|
|
920
|
+
eq(follows.status, "accepted"),
|
|
921
|
+
),
|
|
922
|
+
)
|
|
923
|
+
.limit(MAX_SHARED_INBOX_FANOUT);
|
|
924
|
+
|
|
925
|
+
const localFollowerApIds = followerRows
|
|
926
|
+
.map((row) => row.followerApId)
|
|
927
|
+
.filter((apId) => isLocal(apId, baseUrl));
|
|
928
|
+
if (localFollowerApIds.length === 0) return [];
|
|
929
|
+
|
|
930
|
+
// Chunk the IN(...) lookup: the fan-out is capped at MAX_SHARED_INBOX_FANOUT
|
|
931
|
+
// (1000) and D1 allows at most 100 bound parameters per query. The id slices
|
|
932
|
+
// are disjoint, so flattening the per-chunk actor rows is collision-free.
|
|
933
|
+
const chunks = await Promise.all(
|
|
934
|
+
chunkForInClause(localFollowerApIds).map((ids) =>
|
|
935
|
+
db.query.actors.findMany({ where: inArray(actors.apId, ids) }),
|
|
936
|
+
),
|
|
937
|
+
);
|
|
938
|
+
return chunks.flat();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Resolve the LOCAL actor named by `activity.object` (an actor IRI). Used for
|
|
943
|
+
* object-actor-scoped activities (e.g. `Follow`) delivered to the SHARED inbox:
|
|
944
|
+
* their recipient is the actor in `object`, not the followers of the sender, so
|
|
945
|
+
* they must not go through the follower fan-out. Returns null when the object
|
|
946
|
+
* is missing, remote, or not a known local actor.
|
|
947
|
+
*/
|
|
948
|
+
async function resolveLocalActorFromObject(
|
|
949
|
+
c: HonoContext,
|
|
950
|
+
activity: Activity,
|
|
951
|
+
baseUrl: string,
|
|
952
|
+
): Promise<ActorRow | null> {
|
|
953
|
+
const objectId = getActivityObjectId(activity);
|
|
954
|
+
if (!objectId || !isLocal(objectId, baseUrl)) return null;
|
|
955
|
+
const db = c.get("db");
|
|
956
|
+
const row = await db.query.actors.findFirst({
|
|
957
|
+
where: eq(actors.apId, objectId),
|
|
958
|
+
});
|
|
959
|
+
return row ?? null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function findLocalActorByApId(
|
|
963
|
+
c: HonoContext,
|
|
964
|
+
apId: string,
|
|
965
|
+
baseUrl: string,
|
|
966
|
+
): Promise<ActorRow | null> {
|
|
967
|
+
if (!isLocal(apId, baseUrl)) return null;
|
|
968
|
+
const row = await c.get("db").query.actors.findFirst({
|
|
969
|
+
where: eq(actors.apId, apId),
|
|
970
|
+
});
|
|
971
|
+
return row ?? null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Classify a shared-inbox activity whose recipient is an ACTOR named by the
|
|
976
|
+
* activity (not the followers of the sender), and resolve that local target:
|
|
977
|
+
* - `Follow` / `Block`: the target is `activity.object` (the followed/blocked
|
|
978
|
+
* actor). handleFollow/handleBlock key off `recipient`, so it MUST be that
|
|
979
|
+
* actor, never a follower of the sender.
|
|
980
|
+
* - `Undo(Follow|Block)`: undoFollow decrements `recipient`'s followerCount, so
|
|
981
|
+
* the recipient must be the followed actor. Resolve it from the wrapped
|
|
982
|
+
* activity's object (typed inner) or by looking up the referenced follow edge
|
|
983
|
+
* (bare-string inner). Undo(Like|Announce) is actor-keyed + idempotent, so it
|
|
984
|
+
* is NOT actor-scoped and keeps the follower fan-out (`scoped: false`).
|
|
985
|
+
* `scoped: true` with `target: null` = an actor-scoped activity that names no
|
|
986
|
+
* known LOCAL actor → an honest no-op (do not fan out to the sender's followers).
|
|
987
|
+
*/
|
|
988
|
+
async function resolveObjectActorTarget(
|
|
989
|
+
c: HonoContext,
|
|
990
|
+
activityType: string,
|
|
991
|
+
activity: Activity,
|
|
992
|
+
baseUrl: string,
|
|
993
|
+
): Promise<{ scoped: boolean; target: ActorRow | null }> {
|
|
994
|
+
if (activityType === "Follow" || activityType === "Block") {
|
|
995
|
+
return {
|
|
996
|
+
scoped: true,
|
|
997
|
+
target: await resolveLocalActorFromObject(c, activity, baseUrl),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
if (activityType === "Undo") {
|
|
1001
|
+
const inner = getActivityObject(activity) as {
|
|
1002
|
+
type?: string | string[];
|
|
1003
|
+
object?: unknown;
|
|
1004
|
+
} | null;
|
|
1005
|
+
const innerObjectId = inner
|
|
1006
|
+
? typeof inner.object === "string"
|
|
1007
|
+
? inner.object
|
|
1008
|
+
: ((inner.object as { id?: string } | undefined)?.id ?? null)
|
|
1009
|
+
: null;
|
|
1010
|
+
|
|
1011
|
+
// Undo(Block): the target is the blocked actor named in `inner.object`.
|
|
1012
|
+
// Block has no activity-id-keyed edge, so an absent object is a null no-op.
|
|
1013
|
+
if (typeIncludes(inner?.type, "Block")) {
|
|
1014
|
+
return {
|
|
1015
|
+
scoped: true,
|
|
1016
|
+
target: innerObjectId
|
|
1017
|
+
? await findLocalActorByApId(c, innerObjectId, baseUrl)
|
|
1018
|
+
: null,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Undo(Follow): the followed actor — undoFollow keys the followerCount
|
|
1023
|
+
// decrement on `recipient`, so the recipient MUST be the followed actor.
|
|
1024
|
+
// Resolve it from `inner.object` if present, else from the referenced follow
|
|
1025
|
+
// EDGE (a typed inner that carries only its own id, a bare-string activity
|
|
1026
|
+
// id, OR a typeless object inner — all mirror the per-user inbox's
|
|
1027
|
+
// findFollowByActivityId path). An inner WITHOUT an explicit "Follow" type
|
|
1028
|
+
// (bare-string or typeless object) is treated as a POSSIBLE Undo(Follow); if
|
|
1029
|
+
// it resolves no local follow edge it is left to the fan-out, because it may
|
|
1030
|
+
// be an Undo(Like|Announce) by id whose actor-keyed handler must still run.
|
|
1031
|
+
if (
|
|
1032
|
+
typeIncludes(inner?.type, "Follow") ||
|
|
1033
|
+
inner == null ||
|
|
1034
|
+
inner.type == null
|
|
1035
|
+
) {
|
|
1036
|
+
if (innerObjectId && isLocal(innerObjectId, baseUrl)) {
|
|
1037
|
+
return {
|
|
1038
|
+
scoped: true,
|
|
1039
|
+
target: await findLocalActorByApId(c, innerObjectId, baseUrl),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
const innerId = getActivityObjectId(activity);
|
|
1043
|
+
if (innerId) {
|
|
1044
|
+
const follow = await findFollowByActivityId(c.get("db"), innerId);
|
|
1045
|
+
if (follow && isLocal(follow.followingApId, baseUrl)) {
|
|
1046
|
+
return {
|
|
1047
|
+
scoped: true,
|
|
1048
|
+
target: await findLocalActorByApId(
|
|
1049
|
+
c,
|
|
1050
|
+
follow.followingApId,
|
|
1051
|
+
baseUrl,
|
|
1052
|
+
),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// A typed Follow inner is object-scoped even with an unresolvable edge
|
|
1057
|
+
// (commit a no-op; do NOT fan out to the sender's followers). An inner with
|
|
1058
|
+
// no explicit Follow type that resolved no follow edge keeps the fan-out —
|
|
1059
|
+
// it may be an Undo(Like|Announce) whose decrement the handler must apply.
|
|
1060
|
+
return inner?.type === "Follow"
|
|
1061
|
+
? { scoped: true, target: null }
|
|
1062
|
+
: { scoped: false, target: null };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Undo(Like|Announce|…) — actor-keyed + idempotent; keep the follower fan-out.
|
|
1066
|
+
return { scoped: false, target: null };
|
|
1067
|
+
}
|
|
1068
|
+
return { scoped: false, target: null };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
ap.post("/ap/inbox", async (c) => {
|
|
1072
|
+
const baseUrl = c.env.APP_URL;
|
|
1073
|
+
|
|
1074
|
+
const result = await verifyAndParseInbox(c, baseUrl);
|
|
1075
|
+
if (result instanceof Response) return result;
|
|
1076
|
+
|
|
1077
|
+
const throttled = await applyInboxDomainRateLimit(c, result.actor);
|
|
1078
|
+
if (throttled) return throttled;
|
|
1079
|
+
|
|
1080
|
+
const claim = await claimActivityForDispatch(c, result);
|
|
1081
|
+
if (claim instanceof Response) return claim;
|
|
1082
|
+
|
|
1083
|
+
const { activity, activityType, actor } = result;
|
|
1084
|
+
|
|
1085
|
+
// The fan-out below may throw before any dispatch runs (e.g. actor cache or
|
|
1086
|
+
// follower resolution faults). On such a failure we leave the row uncommitted
|
|
1087
|
+
// (processed = 0) so a peer retry re-dispatches and completes delivery rather
|
|
1088
|
+
// than being suppressed by the dedup row (#9); we commit it once the fan-out
|
|
1089
|
+
// has run so retries are skipped.
|
|
1090
|
+
try {
|
|
1091
|
+
await cacheRemoteActor(c, actor, baseUrl);
|
|
1092
|
+
|
|
1093
|
+
if (RECIPIENT_INDEPENDENT_TYPES.has(activityType)) {
|
|
1094
|
+
// These handlers ignore the recipient; dispatch once. We pass a synthetic
|
|
1095
|
+
// recipient context derived from the activity actor so the handler
|
|
1096
|
+
// signature is satisfied without implying a specific local target.
|
|
1097
|
+
await dispatchUserActivity(c, activityType, activity, {
|
|
1098
|
+
recipient: { apId: actor } as ActorRow,
|
|
1099
|
+
actor,
|
|
1100
|
+
baseUrl,
|
|
1101
|
+
});
|
|
1102
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
1103
|
+
return c.body(null, 202);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Object-actor-scoped activities (Follow / Block / Undo(Follow|Block)) are
|
|
1107
|
+
// addressed to the actor NAMED by the activity, NOT to followers of the
|
|
1108
|
+
// sender. Routing them through the follower fan-out below would make the
|
|
1109
|
+
// handler key off the wrong actor (bogus edge / Accept from the wrong actor
|
|
1110
|
+
// / followerCount drift on the wrong actor) or — when the sender has no
|
|
1111
|
+
// local followers — silently DROP the request entirely. Resolve the target
|
|
1112
|
+
// and dispatch once (mirrors the per-user inbox). Correctly-addressed peers
|
|
1113
|
+
// hit /ap/users/:username/inbox; this guards peers that point them here.
|
|
1114
|
+
const objectScoped = await resolveObjectActorTarget(
|
|
1115
|
+
c,
|
|
1116
|
+
activityType,
|
|
1117
|
+
activity,
|
|
1118
|
+
baseUrl,
|
|
1119
|
+
);
|
|
1120
|
+
if (objectScoped.scoped) {
|
|
1121
|
+
if (objectScoped.target) {
|
|
1122
|
+
await dispatchUserActivity(c, activityType, activity, {
|
|
1123
|
+
recipient: objectScoped.target,
|
|
1124
|
+
actor,
|
|
1125
|
+
baseUrl,
|
|
1126
|
+
});
|
|
1127
|
+
} else {
|
|
1128
|
+
log.info("Shared-inbox object-actor activity names no local target", {
|
|
1129
|
+
event: "ap.shared_inbox.object_actor_no_target",
|
|
1130
|
+
activityType,
|
|
1131
|
+
actor,
|
|
1132
|
+
object: getActivityObjectId(activity),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
1136
|
+
return c.body(null, 202);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Recipient-scoped: fan out to every local follower of the sending actor.
|
|
1140
|
+
const recipients = await resolveLocalFollowerRecipients(c, actor, baseUrl);
|
|
1141
|
+
if (recipients.length === 0) {
|
|
1142
|
+
// No local subscribers for this actor — an honest no-op delivery. Commit
|
|
1143
|
+
// the claim so the no-op is not retried indefinitely.
|
|
1144
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
1145
|
+
log.info("Shared-inbox activity had no local recipients", {
|
|
1146
|
+
event: "ap.shared_inbox.no_recipients",
|
|
1147
|
+
activityType,
|
|
1148
|
+
actor,
|
|
1149
|
+
});
|
|
1150
|
+
return c.body(null, 202);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
for (const recipient of recipients) {
|
|
1154
|
+
// Isolate per-recipient failures: a single local recipient whose handler
|
|
1155
|
+
// throws must not abort fan-out to the others or turn the whole shared
|
|
1156
|
+
// delivery into a 5xx (which would make the sending peer retry and
|
|
1157
|
+
// redeliver to every recipient).
|
|
1158
|
+
try {
|
|
1159
|
+
await dispatchUserActivity(c, activityType, activity, {
|
|
1160
|
+
recipient,
|
|
1161
|
+
actor,
|
|
1162
|
+
baseUrl,
|
|
1163
|
+
});
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
log.error("Shared-inbox dispatch failed for one recipient", {
|
|
1166
|
+
event: "ap.shared_inbox.dispatch_error",
|
|
1167
|
+
activityType,
|
|
1168
|
+
actor,
|
|
1169
|
+
recipient: recipient.apId,
|
|
1170
|
+
error: e,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Fan-out attempted for every resolved recipient (per-recipient failures
|
|
1176
|
+
// are isolated above). Commit the claim so a peer retry does not redeliver
|
|
1177
|
+
// to every local follower.
|
|
1178
|
+
await commitActivityDispatch(c, claim.activityId);
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
log.error("Shared-inbox dispatch failed", {
|
|
1181
|
+
event: "ap.shared_inbox.dispatch_error",
|
|
1182
|
+
activityType,
|
|
1183
|
+
actor,
|
|
1184
|
+
error: e,
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return c.body(null, 202);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
export default ap;
|