@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,1634 @@
|
|
|
1
|
+
import type { Database } from "../../../../db/index.ts";
|
|
2
|
+
import { and, count, eq, gt, inArray, isNotNull, or, sql } from "drizzle-orm";
|
|
3
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
4
|
+
import {
|
|
5
|
+
activities,
|
|
6
|
+
actorCache,
|
|
7
|
+
actors,
|
|
8
|
+
announces,
|
|
9
|
+
blocks,
|
|
10
|
+
bookmarks,
|
|
11
|
+
follows,
|
|
12
|
+
inbox as inboxTable,
|
|
13
|
+
likes,
|
|
14
|
+
objectRecipients,
|
|
15
|
+
objects,
|
|
16
|
+
storyShares,
|
|
17
|
+
storyViews,
|
|
18
|
+
storyVotes,
|
|
19
|
+
} from "../../../../db/index.ts";
|
|
20
|
+
import { upsertActivityAndNotify } from "./inbox-shared-helpers.ts";
|
|
21
|
+
import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
|
|
22
|
+
import {
|
|
23
|
+
deleteObjectCascade,
|
|
24
|
+
purgeMediaBlobs,
|
|
25
|
+
} from "../../posts/delete-cascade.ts";
|
|
26
|
+
import {
|
|
27
|
+
boundAttachmentsJson,
|
|
28
|
+
boundInboundContent,
|
|
29
|
+
boundInboundSummary,
|
|
30
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
31
|
+
MAX_POST_CONTENT_LENGTH,
|
|
32
|
+
MAX_POST_SUMMARY_LENGTH,
|
|
33
|
+
truncate,
|
|
34
|
+
} from "../../posts/transformers.ts";
|
|
35
|
+
import {
|
|
36
|
+
activityApId,
|
|
37
|
+
generateId,
|
|
38
|
+
getDomain,
|
|
39
|
+
isLocal,
|
|
40
|
+
isSafeRemoteUrl,
|
|
41
|
+
objectApId,
|
|
42
|
+
} from "../../../federation-helpers.ts";
|
|
43
|
+
import { getConversationId } from "../../dm/query-helpers.ts";
|
|
44
|
+
import {
|
|
45
|
+
fetchAndUpsertActorCache,
|
|
46
|
+
getInstanceFetchSignerByDb,
|
|
47
|
+
} from "../../../lib/activitypub-actor-cache.ts";
|
|
48
|
+
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
49
|
+
import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
|
|
50
|
+
import { chunkForInClause } from "../../../lib/chunk.ts";
|
|
51
|
+
import {
|
|
52
|
+
actorIsBlockedBy,
|
|
53
|
+
canViewerReadObjectFull,
|
|
54
|
+
} from "../../../lib/post-visibility.ts";
|
|
55
|
+
import { logger } from "../../../lib/logger.ts";
|
|
56
|
+
import {
|
|
57
|
+
type Activity,
|
|
58
|
+
type ActivityContext,
|
|
59
|
+
type ActivityObject,
|
|
60
|
+
getActivityObject,
|
|
61
|
+
getActivityObjectId,
|
|
62
|
+
type StoryOverlay,
|
|
63
|
+
typeIncludes,
|
|
64
|
+
} from "../inbox-types.ts";
|
|
65
|
+
|
|
66
|
+
const log = logger.child({ component: "activitypub.inbox.content" });
|
|
67
|
+
|
|
68
|
+
type ActorRow = typeof actors.$inferSelect;
|
|
69
|
+
|
|
70
|
+
// normalizeInboundTimestamp now lives in ./inbound-timestamp.ts (shared with the
|
|
71
|
+
// federated group-chat path) — imported at the top of this file.
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch` and
|
|
75
|
+
// the inbox-interaction / inbox-shared helper). D1 has no interactive
|
|
76
|
+
// transactions, but both the D1 and libsql drivers expose `db.batch([...])`,
|
|
77
|
+
// which commits a list of prepared statements atomically. The shared
|
|
78
|
+
// `Database` union aliases the abstract `BaseSQLiteDatabase` base (which does
|
|
79
|
+
// not surface `batch`), so we narrow to the concrete batch surface here.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
83
|
+
interface BatchableDb {
|
|
84
|
+
batch(
|
|
85
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
86
|
+
): Promise<unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runBatch(
|
|
90
|
+
db: Database,
|
|
91
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await (db as unknown as BatchableDb).batch(statements);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Federation blocklist enforcement lives centrally in
|
|
97
|
+
// `verifyAndParseInbox` (routes/activitypub/inbox.ts): every inbound
|
|
98
|
+
// activity is gated once there before any handler runs, so the per-handler
|
|
99
|
+
// gate that previously lived here is intentionally absent.
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Inline helpers
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function isStoryType(type: string | string[] | undefined): boolean {
|
|
106
|
+
if (!type) return false;
|
|
107
|
+
return Array.isArray(type) ? type.includes("Story") : type === "Story";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The actor object types whose inbound Update represents a remote
|
|
111
|
+
// profile / avatar / public-key change that should refresh the actor cache.
|
|
112
|
+
const ACTOR_OBJECT_TYPES = new Set([
|
|
113
|
+
"Person",
|
|
114
|
+
"Service",
|
|
115
|
+
"Group",
|
|
116
|
+
"Organization",
|
|
117
|
+
"Application",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
// Minimum interval between outbound actor re-fetches triggered by an inbound
|
|
121
|
+
// Update(actor). Within this window we rely on the existing cache row (and the
|
|
122
|
+
// normal actor-cache TTL) instead of re-fetching, so a flood of Update
|
|
123
|
+
// activities cannot amplify into a flood of outbound fetches.
|
|
124
|
+
const ACTOR_UPDATE_REFETCH_COOLDOWN_MS = 60_000;
|
|
125
|
+
|
|
126
|
+
function isActorTypeUpdate(type: string | string[] | undefined): boolean {
|
|
127
|
+
if (!type) return false;
|
|
128
|
+
return Array.isArray(type)
|
|
129
|
+
? type.some((t) => ACTOR_OBJECT_TYPES.has(t))
|
|
130
|
+
: ACTOR_OBJECT_TYPES.has(type);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The ActivityStreams public-collection magic value, including the legacy
|
|
134
|
+
// short forms some implementations still emit.
|
|
135
|
+
const PUBLIC_COLLECTION = new Set([
|
|
136
|
+
"https://www.w3.org/ns/activitystreams#Public",
|
|
137
|
+
"as:Public",
|
|
138
|
+
"Public",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
function addressesPublic(addresses: string[]): boolean {
|
|
142
|
+
return addresses.some((a) => PUBLIC_COLLECTION.has(a));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A note addressed to a followers collection (the author's `<actor>/followers`)
|
|
146
|
+
// and NOT to Public is a followers-only post. We match any `/followers`
|
|
147
|
+
// collection by suffix (mirrors isDirectNote), which covers the author's
|
|
148
|
+
// collection without needing to resolve it.
|
|
149
|
+
function addressesFollowers(addresses: string[]): boolean {
|
|
150
|
+
return addresses.some((a) => a.endsWith("/followers"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Recipient-INDEPENDENT visibility classification for an inbound generic Note,
|
|
155
|
+
* mirroring the local outbound addressing contract. CRITICAL invariant: a
|
|
156
|
+
* non-public Note is NEVER classified as "unlisted" (world-readable). Direct
|
|
157
|
+
* (addressed-to-specific-actors-only) Notes are diverted BEFORE this is reached
|
|
158
|
+
* (insertDirectNote / the direct-shaped skip), so the residual here is:
|
|
159
|
+
* - "public" — the Public collection is in `to`;
|
|
160
|
+
* - "unlisted" — Public is only in `cc` (Mastodon-style unlisted), or the
|
|
161
|
+
* note carries no usable addressing at all;
|
|
162
|
+
* - "followers" — a followers collection is addressed and Public is absent.
|
|
163
|
+
* Previously this was derived solely from `to.includes(Public)`, so a remote
|
|
164
|
+
* followers-only post (Public absent) was silently downgraded to "unlisted" and
|
|
165
|
+
* became world-readable. */
|
|
166
|
+
function classifyInboundNoteVisibility(object: {
|
|
167
|
+
to?: string[];
|
|
168
|
+
cc?: string[];
|
|
169
|
+
}): "public" | "unlisted" | "followers" {
|
|
170
|
+
const to = object.to ?? [];
|
|
171
|
+
const cc = object.cc ?? [];
|
|
172
|
+
if (addressesPublic(to)) return "public";
|
|
173
|
+
if (addressesPublic(cc)) return "unlisted";
|
|
174
|
+
if (addressesFollowers([...to, ...cc])) return "followers";
|
|
175
|
+
return "unlisted";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A Note addressed ONLY to specific actors — no Public, no followers collection
|
|
180
|
+
* — i.e. a direct/DM-shaped Note. When such a Note reaches a shared-inbox fan-out
|
|
181
|
+
* recipient it is NOT addressed to, it must NOT be stored as a world-readable
|
|
182
|
+
* generic Note; the addressed local actor's own delivery handles it via
|
|
183
|
+
* insertDirectNote. Recipient-independent (keyed on the activity's own
|
|
184
|
+
* addressing), unlike isDirectNote.
|
|
185
|
+
*/
|
|
186
|
+
function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
|
|
187
|
+
const all = [...(object.to ?? []), ...(object.cc ?? [])];
|
|
188
|
+
if (all.length === 0) return false;
|
|
189
|
+
if (addressesPublic(all)) return false;
|
|
190
|
+
if (addressesFollowers(all)) return false;
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Cap persisted addressing arrays so a remote cannot bloat a row with a huge
|
|
195
|
+
// to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
|
|
196
|
+
// recipient (mention) gate working.
|
|
197
|
+
const MAX_ADDRESS_ENTRIES = 64;
|
|
198
|
+
function boundAddressJson(addresses: string[] | undefined): string {
|
|
199
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
|
|
200
|
+
return JSON.stringify(
|
|
201
|
+
addresses
|
|
202
|
+
.filter((a) => typeof a === "string")
|
|
203
|
+
.slice(0, MAX_ADDRESS_ENTRIES),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reject an inbound object whose `object.id` is asserted under a host the
|
|
209
|
+
* delivering actor does not control (object-ID squatting / cross-origin
|
|
210
|
+
* injection). A remote actor may only Create objects under its own origin, and
|
|
211
|
+
* never under the local domain. Returns true when the object id must be
|
|
212
|
+
* rejected. Mirrors the ownership checks already enforced for Delete/Update.
|
|
213
|
+
*/
|
|
214
|
+
function isObjectIdOriginMismatch(
|
|
215
|
+
objectId: string | undefined,
|
|
216
|
+
actor: string,
|
|
217
|
+
baseUrl: string,
|
|
218
|
+
): boolean {
|
|
219
|
+
if (!objectId) return false;
|
|
220
|
+
// A remote actor must never assert a local-domain object id.
|
|
221
|
+
if (isLocal(objectId, baseUrl)) return true;
|
|
222
|
+
try {
|
|
223
|
+
return getDomain(objectId) !== getDomain(actor);
|
|
224
|
+
} catch {
|
|
225
|
+
// Unparseable object id: treat as a mismatch (reject) rather than insert.
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extract the `href` of every `Mention` tag on an inbound object. AS2 `tag` may
|
|
232
|
+
* be an array, a single object, or absent; each Mention carries the mentioned
|
|
233
|
+
* actor's id in `href`. Used to fan-in mention notifications for federated posts
|
|
234
|
+
* (mirrors the local processMentions path).
|
|
235
|
+
*/
|
|
236
|
+
// Cap on the number of distinct local mentions a single inbound activity can
|
|
237
|
+
// fan a notification out to. `object.tag` is bounded only by the 512 KiB inbox
|
|
238
|
+
// payload cap (~9-10k Mention entries), and each one used to cost a serial
|
|
239
|
+
// `actors` SELECT — an attacker could blow the Workers subrequest budget with
|
|
240
|
+
// one signed POST. Real posts mention a handful of people, so this ceiling is
|
|
241
|
+
// generous while bounding the worst case.
|
|
242
|
+
const MAX_INBOUND_MENTIONS = 50;
|
|
243
|
+
|
|
244
|
+
function extractMentionHrefs(tag: unknown): string[] {
|
|
245
|
+
const arr = Array.isArray(tag) ? tag : tag ? [tag] : [];
|
|
246
|
+
const hrefs: string[] = [];
|
|
247
|
+
for (const t of arr) {
|
|
248
|
+
if (
|
|
249
|
+
t &&
|
|
250
|
+
typeof t === "object" &&
|
|
251
|
+
typeIncludes((t as { type?: string | string[] }).type, "Mention")
|
|
252
|
+
) {
|
|
253
|
+
const href = (t as { href?: unknown }).href;
|
|
254
|
+
if (typeof href === "string" && href) hrefs.push(href);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return hrefs;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Detect an inbound direct (DM) Note: it is addressed (in `to`/`cc`) to one or
|
|
262
|
+
* more recipients but NOT to the Public collection and NOT to a followers
|
|
263
|
+
* collection. The local addressed recipient is the inbox owner (`recipient`),
|
|
264
|
+
* who is necessarily a known local actor row. Mirrors the outbound DM contract
|
|
265
|
+
* in dm/messages.ts (visibility="direct", to=[recipient]).
|
|
266
|
+
*/
|
|
267
|
+
function isDirectNote(
|
|
268
|
+
object: { to?: string[]; cc?: string[] },
|
|
269
|
+
recipient: ActorRow,
|
|
270
|
+
): boolean {
|
|
271
|
+
const to = object.to ?? [];
|
|
272
|
+
const cc = object.cc ?? [];
|
|
273
|
+
const all = [...to, ...cc];
|
|
274
|
+
if (all.length === 0) return false;
|
|
275
|
+
// Direct notes are never addressed to the Public collection...
|
|
276
|
+
if (addressesPublic(all)) return false;
|
|
277
|
+
// ...nor to a followers collection (follower-only posts are not DMs).
|
|
278
|
+
if (all.some((a) => a.endsWith("/followers"))) return false;
|
|
279
|
+
if (recipient.followersUrl && all.includes(recipient.followersUrl)) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
// The inbox owner must be explicitly addressed in `to` (the recipient set
|
|
283
|
+
// that defines a DM); a mere `cc` mention is not treated as a DM.
|
|
284
|
+
return to.includes(recipient.apId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Route an inbound direct (DM) Note into the recipient's DM inbox /
|
|
289
|
+
* message-request flow, mirroring the local outbound path in
|
|
290
|
+
* dm/messages.ts: a direct-visibility Note row, an objectRecipients row, a
|
|
291
|
+
* stored inbound Create activity, and an inbox row so it surfaces.
|
|
292
|
+
*
|
|
293
|
+
* Scope: a single local recipient (the inbox owner). The outbound DM model is
|
|
294
|
+
* strictly 1:1 (to=[otherApId]) and `objects.conversation` is a single column,
|
|
295
|
+
* so multi-recipient / group direct Notes are intentionally out of scope and
|
|
296
|
+
* fall back to the generic Note insert.
|
|
297
|
+
*/
|
|
298
|
+
async function insertDirectNote(
|
|
299
|
+
db: Database,
|
|
300
|
+
activity: Activity,
|
|
301
|
+
object: ActivityObject,
|
|
302
|
+
objectId: string,
|
|
303
|
+
actor: string,
|
|
304
|
+
recipient: ActorRow,
|
|
305
|
+
baseUrl: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
// Derive the conversation. Honour a sender-supplied `object.conversation`
|
|
308
|
+
// only when it matches the value yurucommu itself would compute for this
|
|
309
|
+
// (sender, localRecipient) pair — otherwise a remote actor could force a
|
|
310
|
+
// message into an arbitrary thread (spoof a reply context). Fall back to the
|
|
311
|
+
// computed id for foreign-origin DMs that carry no/invalid conversation.
|
|
312
|
+
const computedConversation = getConversationId(
|
|
313
|
+
baseUrl,
|
|
314
|
+
actor,
|
|
315
|
+
recipient.apId,
|
|
316
|
+
);
|
|
317
|
+
const conversationId =
|
|
318
|
+
object.conversation === computedConversation
|
|
319
|
+
? object.conversation
|
|
320
|
+
: computedConversation;
|
|
321
|
+
|
|
322
|
+
const attachments = object.attachment
|
|
323
|
+
? JSON.stringify(object.attachment)
|
|
324
|
+
: "[]";
|
|
325
|
+
const publishedAt = normalizeInboundTimestamp(
|
|
326
|
+
object.published,
|
|
327
|
+
new Date().toISOString(),
|
|
328
|
+
);
|
|
329
|
+
const toJson = JSON.stringify([recipient.apId]);
|
|
330
|
+
|
|
331
|
+
// Was the object already present BEFORE this dispatch? This decides whether
|
|
332
|
+
// this delivery is the one that creates the row (and therefore the one that
|
|
333
|
+
// owns the postCount +1 and the inbox surfacing). It is read once here and
|
|
334
|
+
// used only to gate the post-commit side effects; the counter itself is made
|
|
335
|
+
// crash-/retry-safe by the in-batch NOT-EXISTS guard below.
|
|
336
|
+
const existingObject = await db
|
|
337
|
+
.select({ apId: objects.apId })
|
|
338
|
+
.from(objects)
|
|
339
|
+
.where(eq(objects.apId, objectId))
|
|
340
|
+
.get();
|
|
341
|
+
|
|
342
|
+
// #3 (atomicity + idempotency): the object insert and the author postCount
|
|
343
|
+
// bump MUST commit together. Previously the row was inserted
|
|
344
|
+
// (onConflictDoNothing) and postCount bumped in a SEPARATE await; under the
|
|
345
|
+
// claim/processed re-dispatch model a crash between them left the row present
|
|
346
|
+
// but the count un-bumped, and a peer retry's no-op insert SKIPPED the bump →
|
|
347
|
+
// a permanent under-count. Co-commit both in one atomic batch. The postCount
|
|
348
|
+
// +1 runs BEFORE the insert and is guarded by a correlated NOT-EXISTS(object)
|
|
349
|
+
// subquery, so it fires only when THIS batch creates the row (mirrors the
|
|
350
|
+
// edge-absent guard in handleAdd); a duplicate / retry sees the row present →
|
|
351
|
+
// the guard is false and the insert is a no-op, so the count can neither
|
|
352
|
+
// double-bump nor under-count.
|
|
353
|
+
const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
|
|
354
|
+
await runBatch(db, [
|
|
355
|
+
db
|
|
356
|
+
.update(actors)
|
|
357
|
+
.set({ postCount: sql`${actors.postCount} + 1` })
|
|
358
|
+
.where(and(eq(actors.apId, actor), objectAbsent)),
|
|
359
|
+
db
|
|
360
|
+
.insert(objects)
|
|
361
|
+
.values({
|
|
362
|
+
apId: objectId,
|
|
363
|
+
type: "Note",
|
|
364
|
+
attributedTo: actor,
|
|
365
|
+
content: boundInboundContent(object.content),
|
|
366
|
+
summary: boundInboundSummary(object.summary),
|
|
367
|
+
attachmentsJson: boundAttachmentsJson(attachments),
|
|
368
|
+
inReplyTo: object.inReplyTo || null,
|
|
369
|
+
visibility: "direct",
|
|
370
|
+
toJson,
|
|
371
|
+
conversation: conversationId,
|
|
372
|
+
communityApId: null,
|
|
373
|
+
published: publishedAt,
|
|
374
|
+
isLocal: 0,
|
|
375
|
+
})
|
|
376
|
+
.onConflictDoNothing(),
|
|
377
|
+
// The recipient link MUST co-commit with the object. Inbound-DM recipient
|
|
378
|
+
// membership is resolved EXCLUSIVELY through object_recipients (contacts /
|
|
379
|
+
// requests / unread-count), so an object that committed WITHOUT its
|
|
380
|
+
// object_recipients row is a DM permanently invisible to the recipient.
|
|
381
|
+
// Previously this insert ran as a SEPARATE await after the batch: a crash /
|
|
382
|
+
// isolate-eviction in that window left exactly that orphan, and the caller's
|
|
383
|
+
// `if (existing) return` made the re-dispatch skip the repair. In-batch with
|
|
384
|
+
// onConflictDoNothing it is atomic (never orphaned) AND idempotent (a retry
|
|
385
|
+
// is a safe no-op). The local-send / community / takos-tools paths already
|
|
386
|
+
// co-commit this row for the same reason.
|
|
387
|
+
db
|
|
388
|
+
.insert(objectRecipients)
|
|
389
|
+
.values({
|
|
390
|
+
objectApId: objectId,
|
|
391
|
+
recipientApId: recipient.apId,
|
|
392
|
+
type: "to",
|
|
393
|
+
})
|
|
394
|
+
.onConflictDoNothing(),
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
if (existingObject) return; // duplicate: no inbox surfacing, no double count
|
|
398
|
+
|
|
399
|
+
// Store the inbound Create and surface it in the recipient's inbox so the DM
|
|
400
|
+
// appears in the conversation / message-requests view.
|
|
401
|
+
const activityId = activity.id || activityApId(baseUrl, generateId());
|
|
402
|
+
await upsertActivityAndNotify(
|
|
403
|
+
db,
|
|
404
|
+
activityId,
|
|
405
|
+
"Create",
|
|
406
|
+
actor,
|
|
407
|
+
objectId,
|
|
408
|
+
activity,
|
|
409
|
+
recipient.apId,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Create handler
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
export async function handleCreate(
|
|
418
|
+
c: ActivityContext,
|
|
419
|
+
activity: Activity,
|
|
420
|
+
recipient: ActorRow,
|
|
421
|
+
actor: string,
|
|
422
|
+
baseUrl: string,
|
|
423
|
+
) {
|
|
424
|
+
const db = c.get("db");
|
|
425
|
+
const object = getActivityObject(activity);
|
|
426
|
+
if (!object) return;
|
|
427
|
+
|
|
428
|
+
// Handle Story type
|
|
429
|
+
if (isStoryType(object.type)) {
|
|
430
|
+
await handleCreateStory(c, activity, actor, baseUrl);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Handle Note type (a remote may send `type` as a string or an array)
|
|
435
|
+
if (!typeIncludes(object.type, "Note")) return;
|
|
436
|
+
|
|
437
|
+
// Same-origin guard: a remote actor may only Create objects under its own
|
|
438
|
+
// origin, never under another host or the local domain. This closes the
|
|
439
|
+
// object-ID squatting / cross-origin injection vector and mirrors the
|
|
440
|
+
// ownership checks already enforced for Delete/Update.
|
|
441
|
+
if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
|
|
442
|
+
log.warn("Create rejected: object id origin does not match actor", {
|
|
443
|
+
event: "ap.create.object_origin_mismatch",
|
|
444
|
+
actor,
|
|
445
|
+
objectId: object.id,
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Direct (DM) Note routing: a Note addressed to the local inbox owner that
|
|
451
|
+
// is neither public nor follower-only belongs in the recipient's DM inbox /
|
|
452
|
+
// message-request flow rather than the generic public Note insert.
|
|
453
|
+
if (object.id && isDirectNote(object, recipient)) {
|
|
454
|
+
// A DM from an actor the recipient has personally BLOCKED must be dropped —
|
|
455
|
+
// mirror the local DM send guard (dm/messages.ts) so the reject+block remedy
|
|
456
|
+
// actually stops federated DM harassment (the operator-scoped federation
|
|
457
|
+
// blocklist checked in verifyAndParseInbox is a SEPARATE mechanism). `actor`
|
|
458
|
+
// is the HTTP-signature-verified signer, so blockedApId=actor is not
|
|
459
|
+
// spoofable. The inbox already ACKs, so dropping here causes no retry storm.
|
|
460
|
+
const blockedBySigner = await db
|
|
461
|
+
.select({ b: blocks.blockerApId })
|
|
462
|
+
.from(blocks)
|
|
463
|
+
.where(
|
|
464
|
+
and(
|
|
465
|
+
eq(blocks.blockerApId, recipient.apId),
|
|
466
|
+
eq(blocks.blockedApId, actor),
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
.get();
|
|
470
|
+
if (blockedBySigner) {
|
|
471
|
+
log.info("Dropped inbound DM from a blocked actor", {
|
|
472
|
+
event: "ap.create.direct_note_blocked",
|
|
473
|
+
actor,
|
|
474
|
+
recipient: recipient.apId,
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const existing = await db
|
|
479
|
+
.select({ apId: objects.apId })
|
|
480
|
+
.from(objects)
|
|
481
|
+
.where(eq(objects.apId, object.id))
|
|
482
|
+
.get();
|
|
483
|
+
if (existing) return;
|
|
484
|
+
await insertDirectNote(
|
|
485
|
+
db,
|
|
486
|
+
activity,
|
|
487
|
+
object,
|
|
488
|
+
object.id,
|
|
489
|
+
actor,
|
|
490
|
+
recipient,
|
|
491
|
+
baseUrl,
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// A direct/DM-shaped Note (addressed only to specific actors, neither Public
|
|
497
|
+
// nor followers) that is NOT addressed to THIS fan-out recipient: the shared
|
|
498
|
+
// inbox calls handleCreate once per local follower of the sender, so a DM
|
|
499
|
+
// addressed to actor A is also dispatched for an unrelated follower B. We must
|
|
500
|
+
// NOT store it as a world-readable generic Note for B — the addressed actor's
|
|
501
|
+
// own delivery handles it via insertDirectNote above. Skip it here.
|
|
502
|
+
if (isDirectShapedNote(object)) {
|
|
503
|
+
log.warn("Skipping direct Note not addressed to this recipient", {
|
|
504
|
+
event: "ap.create.direct_note_not_addressed",
|
|
505
|
+
actor,
|
|
506
|
+
recipient: recipient.apId,
|
|
507
|
+
objectId: object.id,
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const objectId = object.id || objectApId(baseUrl, generateId());
|
|
513
|
+
|
|
514
|
+
// Was the object already present BEFORE this dispatch? This is read ONCE and
|
|
515
|
+
// used only to gate the one-shot side effects (parent notification) below; it
|
|
516
|
+
// intentionally does NOT early-return, because the idempotent count batch must
|
|
517
|
+
// still run on a retry so a parent replyCount left stale by an interrupted
|
|
518
|
+
// prior attempt CONVERGES (mirrors handleInteraction, which always runs the
|
|
519
|
+
// recompute batch and uses the pre-read only to gate the notification).
|
|
520
|
+
const existingBeforeInsert = await db
|
|
521
|
+
.select({ apId: objects.apId })
|
|
522
|
+
.from(objects)
|
|
523
|
+
.where(eq(objects.apId, objectId))
|
|
524
|
+
.get();
|
|
525
|
+
|
|
526
|
+
const attachments = object.attachment
|
|
527
|
+
? JSON.stringify(object.attachment)
|
|
528
|
+
: "[]";
|
|
529
|
+
const publishedAt = normalizeInboundTimestamp(
|
|
530
|
+
object.published,
|
|
531
|
+
new Date().toISOString(),
|
|
532
|
+
);
|
|
533
|
+
const parentObj = object.inReplyTo
|
|
534
|
+
? await db
|
|
535
|
+
.select({
|
|
536
|
+
attributedTo: objects.attributedTo,
|
|
537
|
+
visibility: objects.visibility,
|
|
538
|
+
toJson: objects.toJson,
|
|
539
|
+
ccJson: objects.ccJson,
|
|
540
|
+
audienceJson: objects.audienceJson,
|
|
541
|
+
communityApId: objects.communityApId,
|
|
542
|
+
type: objects.type,
|
|
543
|
+
endTime: objects.endTime,
|
|
544
|
+
})
|
|
545
|
+
.from(objects)
|
|
546
|
+
.where(eq(objects.apId, object.inReplyTo))
|
|
547
|
+
.get()
|
|
548
|
+
: null;
|
|
549
|
+
|
|
550
|
+
// Inbound reply to a LOCAL parent the sending actor cannot read (or that has
|
|
551
|
+
// blocked them) is REFUSED — mirroring the local reply 404 gate
|
|
552
|
+
// (routes/posts/routes.ts). Without this, a remote who merely learns a
|
|
553
|
+
// followers-only / direct(DM) / private-community post's apId could inflate its
|
|
554
|
+
// replyCount, deliver a reply notification to the owner (bypassing a personal
|
|
555
|
+
// block), and — because the stored reply's in_reply_to discloses the parent —
|
|
556
|
+
// build an existence oracle for the restricted post. A legitimate follower /
|
|
557
|
+
// addressed recipient still passes canViewerReadObjectFull, so their reply is
|
|
558
|
+
// ingested normally. (Only gated for LOCAL parents: a remote parent's audience
|
|
559
|
+
// is the remote instance's concern, and we hold no counter/notification for it.)
|
|
560
|
+
if (
|
|
561
|
+
object.inReplyTo &&
|
|
562
|
+
parentObj &&
|
|
563
|
+
isLocal(parentObj.attributedTo, baseUrl) &&
|
|
564
|
+
(!(await canViewerReadObjectFull(db, parentObj, actor)) ||
|
|
565
|
+
(await actorIsBlockedBy(db, parentObj.attributedTo, actor)))
|
|
566
|
+
) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const shouldNotifyParent = !!(
|
|
571
|
+
parentObj && isLocal(parentObj.attributedTo, baseUrl)
|
|
572
|
+
);
|
|
573
|
+
const replyActivityId = shouldNotifyParent
|
|
574
|
+
? activity.id || activityApId(baseUrl, generateId())
|
|
575
|
+
: null;
|
|
576
|
+
|
|
577
|
+
// #3 (atomicity + idempotency): the object insert, the author postCount bump,
|
|
578
|
+
// and (for a reply) the parent replyCount bump MUST commit together.
|
|
579
|
+
// Previously the row was inserted (onConflictDoNothing) and the counts bumped
|
|
580
|
+
// in SEPARATE awaits; under the claim/processed re-dispatch model a crash
|
|
581
|
+
// between them left the row present but the counts un-bumped, and a peer
|
|
582
|
+
// retry's no-op insert SKIPPED the bumps → permanent postCount/replyCount
|
|
583
|
+
// drift. Co-commit them in one atomic batch:
|
|
584
|
+
// - postCount +1 runs BEFORE the insert, guarded by a correlated
|
|
585
|
+
// NOT-EXISTS(object) subquery so it fires only when THIS batch creates
|
|
586
|
+
// the row (mirrors handleAdd's edge-absent guard); a duplicate / retry
|
|
587
|
+
// observes the row present → guard false → no double-bump, no under-count.
|
|
588
|
+
// - replyCount is RECOMPUTED from COUNT(*) of the reply edge set AFTER the
|
|
589
|
+
// insert (mirrors the object-counter recompute in handleInteraction /
|
|
590
|
+
// undoInteraction): exact and idempotent, so a retry after a mid-write
|
|
591
|
+
// crash CONVERGES to the true reply count and a duplicate cannot inflate.
|
|
592
|
+
const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
|
|
593
|
+
const insertObject = db
|
|
594
|
+
.insert(objects)
|
|
595
|
+
.values({
|
|
596
|
+
apId: objectId,
|
|
597
|
+
type: "Note",
|
|
598
|
+
attributedTo: actor,
|
|
599
|
+
content: boundInboundContent(object.content),
|
|
600
|
+
summary: boundInboundSummary(object.summary),
|
|
601
|
+
attachmentsJson: boundAttachmentsJson(attachments),
|
|
602
|
+
inReplyTo: object.inReplyTo || null,
|
|
603
|
+
// Recipient-independent classification: a non-public Note is never stored
|
|
604
|
+
// as world-readable "unlisted". A followers-only post → "followers" (gated
|
|
605
|
+
// by the accepted-follow edge), preserving the remote author's audience.
|
|
606
|
+
visibility: classifyInboundNoteVisibility(object),
|
|
607
|
+
// Persist the addressing so the explicit-recipient (mention) gate in
|
|
608
|
+
// canViewerReadObjectFull / the post-detail route can evaluate.
|
|
609
|
+
toJson: boundAddressJson(object.to),
|
|
610
|
+
ccJson: boundAddressJson(object.cc),
|
|
611
|
+
communityApId: null,
|
|
612
|
+
published: publishedAt,
|
|
613
|
+
isLocal: 0,
|
|
614
|
+
})
|
|
615
|
+
.onConflictDoNothing();
|
|
616
|
+
|
|
617
|
+
const bumpPostCount = db
|
|
618
|
+
.update(actors)
|
|
619
|
+
.set({ postCount: sql`${actors.postCount} + 1` })
|
|
620
|
+
.where(and(eq(actors.apId, actor), objectAbsent));
|
|
621
|
+
|
|
622
|
+
if (object.inReplyTo) {
|
|
623
|
+
const parentId = object.inReplyTo;
|
|
624
|
+
await runBatch(db, [
|
|
625
|
+
bumpPostCount,
|
|
626
|
+
insertObject,
|
|
627
|
+
db
|
|
628
|
+
.update(objects)
|
|
629
|
+
.set({
|
|
630
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
631
|
+
})
|
|
632
|
+
.where(eq(objects.apId, parentId)),
|
|
633
|
+
]);
|
|
634
|
+
} else {
|
|
635
|
+
await runBatch(db, [bumpPostCount, insertObject]);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (existingBeforeInsert) return; // duplicate: no double notification
|
|
639
|
+
|
|
640
|
+
if (shouldNotifyParent && parentObj && replyActivityId) {
|
|
641
|
+
await upsertActivityAndNotify(
|
|
642
|
+
db,
|
|
643
|
+
replyActivityId,
|
|
644
|
+
"Create",
|
|
645
|
+
actor,
|
|
646
|
+
objectId,
|
|
647
|
+
activity,
|
|
648
|
+
parentObj.attributedTo,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Notify every LOCAL actor @-mentioned in the post — the federated counterpart
|
|
653
|
+
// of the local processMentions fan-in. Without this a cross-instance @-mention
|
|
654
|
+
// produced no notification at all (mention is a first-class notification type
|
|
655
|
+
// that only ever fired for local-origin posts). Runs once (the duplicate
|
|
656
|
+
// delivery short-circuits at `existingBeforeInsert` above). Skips the post
|
|
657
|
+
// author and the parent author (already notified by the reply branch) and
|
|
658
|
+
// honors the mentioned actor's block of the sender, mirroring the reply gate.
|
|
659
|
+
const mentionedLocalApIds = new Set<string>();
|
|
660
|
+
for (const href of extractMentionHrefs(object.tag)) {
|
|
661
|
+
if (!isLocal(href, baseUrl)) continue;
|
|
662
|
+
if (href === actor) continue;
|
|
663
|
+
if (parentObj && href === parentObj.attributedTo) continue;
|
|
664
|
+
mentionedLocalApIds.add(href);
|
|
665
|
+
// Bound attacker-controlled fan-out: stop collecting once the cap is hit so
|
|
666
|
+
// a tag array full of distinct fake local hrefs cannot drive unbounded work.
|
|
667
|
+
if (mentionedLocalApIds.size >= MAX_INBOUND_MENTIONS) break;
|
|
668
|
+
}
|
|
669
|
+
if (mentionedLocalApIds.size === 0) return;
|
|
670
|
+
|
|
671
|
+
// Batch-resolve which of the mentioned hrefs are real local actors in one
|
|
672
|
+
// chunked query (D1 caps bound params at 100), instead of a serial SELECT per
|
|
673
|
+
// href. An attacker can pack thousands of distinct fake local hrefs into the
|
|
674
|
+
// tag array; resolving them one-by-one was an N+1 / subrequest-budget
|
|
675
|
+
// amplification. The chunked inArray collapses it to ceil(N/90) queries, and
|
|
676
|
+
// only the resolved (existing) actors are then notified.
|
|
677
|
+
const existingLocalApIds = (
|
|
678
|
+
await Promise.all(
|
|
679
|
+
chunkForInClause([...mentionedLocalApIds]).map((chunk) =>
|
|
680
|
+
db
|
|
681
|
+
.select({ apId: actors.apId })
|
|
682
|
+
.from(actors)
|
|
683
|
+
.where(inArray(actors.apId, chunk)),
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
).flat();
|
|
687
|
+
|
|
688
|
+
for (const { apId: mentionedApId } of existingLocalApIds) {
|
|
689
|
+
if (await actorIsBlockedBy(db, mentionedApId, actor)) continue;
|
|
690
|
+
await upsertActivityAndNotify(
|
|
691
|
+
db,
|
|
692
|
+
activityApId(baseUrl, generateId()),
|
|
693
|
+
"Create",
|
|
694
|
+
actor,
|
|
695
|
+
objectId,
|
|
696
|
+
activity,
|
|
697
|
+
mentionedApId,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Create(Story) handler
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
export async function handleCreateStory(
|
|
707
|
+
c: ActivityContext,
|
|
708
|
+
activity: Activity,
|
|
709
|
+
actor: string,
|
|
710
|
+
baseUrl: string,
|
|
711
|
+
) {
|
|
712
|
+
const db = c.get("db");
|
|
713
|
+
const object = getActivityObject(activity);
|
|
714
|
+
if (!object) return;
|
|
715
|
+
|
|
716
|
+
// Same-origin guard: reject a story whose object id is squatted under another
|
|
717
|
+
// host or the local domain (see handleCreate for rationale).
|
|
718
|
+
if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
|
|
719
|
+
log.warn("Create(Story) rejected: object id origin does not match actor", {
|
|
720
|
+
event: "ap.story.object_origin_mismatch",
|
|
721
|
+
actor,
|
|
722
|
+
objectId: object.id,
|
|
723
|
+
});
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const objectId = object.id || objectApId(baseUrl, generateId());
|
|
728
|
+
|
|
729
|
+
// Per-user block: drop a Story from an actor the local owner has blocked,
|
|
730
|
+
// mirroring the inbound DM blockedBySigner drop + the inbound Like/Announce/
|
|
731
|
+
// Follow/reply block gates. The other inbound owner-visible paths all enforce
|
|
732
|
+
// the per-user `blocks` table; Create(Story) was the gap, so a blocked actor's
|
|
733
|
+
// stories were still stored (consuming the per-author cap + retrievable via
|
|
734
|
+
// GET /api/posts/:id). Single-user instance: any blocks row blocking this actor
|
|
735
|
+
// is the owner's block.
|
|
736
|
+
const blockedByOwner = await db
|
|
737
|
+
.select({ b: blocks.blockerApId })
|
|
738
|
+
.from(blocks)
|
|
739
|
+
.where(eq(blocks.blockedApId, actor))
|
|
740
|
+
.get();
|
|
741
|
+
if (blockedByOwner) return;
|
|
742
|
+
|
|
743
|
+
// Check if story already exists
|
|
744
|
+
const existing = await db
|
|
745
|
+
.select({ apId: objects.apId })
|
|
746
|
+
.from(objects)
|
|
747
|
+
.where(eq(objects.apId, objectId))
|
|
748
|
+
.get();
|
|
749
|
+
if (existing) return;
|
|
750
|
+
|
|
751
|
+
// Per-author flood cap. A hostile remote could Create() an unbounded number of
|
|
752
|
+
// Stories to bloat our feed/storage (each carries an attachment blob + caption).
|
|
753
|
+
// The local create path is naturally bounded by the owner; inbound has no such
|
|
754
|
+
// bound, so cap the concurrent LIVE (non-expired) remote stories per author.
|
|
755
|
+
// Expired stories are reaped, so this limits the live set, not lifetime volume.
|
|
756
|
+
const MAX_INBOUND_STORIES_PER_ACTOR = 50;
|
|
757
|
+
const nowIso = new Date().toISOString();
|
|
758
|
+
const liveStories = await db
|
|
759
|
+
.select({ n: count() })
|
|
760
|
+
.from(objects)
|
|
761
|
+
.where(
|
|
762
|
+
and(
|
|
763
|
+
eq(objects.attributedTo, actor),
|
|
764
|
+
eq(objects.type, "Story"),
|
|
765
|
+
eq(objects.isLocal, 0),
|
|
766
|
+
gt(objects.endTime, nowIso),
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
.get();
|
|
770
|
+
if ((liveStories?.n ?? 0) >= MAX_INBOUND_STORIES_PER_ACTOR) {
|
|
771
|
+
log.warn("Create(Story) rejected: author live-story cap reached", {
|
|
772
|
+
event: "ap.story.author_cap",
|
|
773
|
+
actor,
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// attachment validation (required)
|
|
779
|
+
if (!object.attachment) {
|
|
780
|
+
log.error("Remote story has no attachment", {
|
|
781
|
+
event: "ap.story.missing_attachment",
|
|
782
|
+
objectId,
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Normalize attachment (handle array or single object)
|
|
788
|
+
const attachmentArray = Array.isArray(object.attachment)
|
|
789
|
+
? object.attachment
|
|
790
|
+
: [object.attachment];
|
|
791
|
+
const attachment = attachmentArray[0] as {
|
|
792
|
+
url?: string;
|
|
793
|
+
mediaType?: string;
|
|
794
|
+
width?: number;
|
|
795
|
+
height?: number;
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
if (!attachment || !attachment.url) {
|
|
799
|
+
log.error("Remote story attachment has no URL", {
|
|
800
|
+
event: "ap.story.attachment_missing_url",
|
|
801
|
+
objectId,
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// overlays validation (optional, validate if present). Cap the COUNT — the
|
|
807
|
+
// local create path bounds overlays via validateOverlays (MAX_OVERLAYS=20),
|
|
808
|
+
// and a hostile remote must not pad an unbounded array into attachments_json.
|
|
809
|
+
const MAX_INBOUND_OVERLAYS = 20;
|
|
810
|
+
let overlays: StoryOverlay[] | undefined;
|
|
811
|
+
if (Array.isArray(object.overlays)) {
|
|
812
|
+
const filtered = (object.overlays as StoryOverlay[])
|
|
813
|
+
.filter(
|
|
814
|
+
(o: StoryOverlay) =>
|
|
815
|
+
o &&
|
|
816
|
+
o.position &&
|
|
817
|
+
typeof o.position.x === "number" &&
|
|
818
|
+
typeof o.position.y === "number",
|
|
819
|
+
)
|
|
820
|
+
.slice(0, MAX_INBOUND_OVERLAYS);
|
|
821
|
+
// Keep at most ONE Question (poll) overlay — votes are keyed only by
|
|
822
|
+
// (storyApId, actorApId) and tallied by optionIndex with no question
|
|
823
|
+
// dimension, so a second poll would conflate tallies. Mirror validateOverlays.
|
|
824
|
+
let seenQuestion = false;
|
|
825
|
+
const capped = filtered.filter((o: StoryOverlay) => {
|
|
826
|
+
if (o.type === "Question") {
|
|
827
|
+
if (seenQuestion) return false;
|
|
828
|
+
seenQuestion = true;
|
|
829
|
+
}
|
|
830
|
+
return true;
|
|
831
|
+
});
|
|
832
|
+
if (capped.length > 0) overlays = capped;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Build attachments_json
|
|
836
|
+
const attachmentData = {
|
|
837
|
+
attachment: {
|
|
838
|
+
r2_key: "", // Remote stories don't have local R2 key
|
|
839
|
+
content_type: attachment.mediaType || "image/jpeg",
|
|
840
|
+
url: attachment.url,
|
|
841
|
+
width: attachment.width || 1080,
|
|
842
|
+
height: attachment.height || 1920,
|
|
843
|
+
},
|
|
844
|
+
displayDuration:
|
|
845
|
+
(object as { displayDuration?: string }).displayDuration || "PT5S",
|
|
846
|
+
// The remote caption arrives as the AS2 Note `content`; persist it (bounded
|
|
847
|
+
// to the same local content cap as every other inbound Note path) so the
|
|
848
|
+
// local renderer shows the same caption as the originating instance.
|
|
849
|
+
caption:
|
|
850
|
+
typeof object.content === "string" && object.content.trim().length > 0
|
|
851
|
+
? boundInboundContent(object.content)
|
|
852
|
+
: undefined,
|
|
853
|
+
overlays,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const now = new Date().toISOString();
|
|
857
|
+
// Clamp the attacker-controlled `endTime`: a story must expire. A non-ISO or
|
|
858
|
+
// far-future value stored verbatim would never satisfy the expiry filter
|
|
859
|
+
// (`lt(endTime, now)`, a lexical compare), so a malicious remote could create
|
|
860
|
+
// never-expiring stories that accumulate forever. Bound it to published + ~25h
|
|
861
|
+
// (the ~24h story lifetime + slack) and normalize to ISO so the compare holds.
|
|
862
|
+
const STORY_MAX_LIFETIME_MS = 25 * 60 * 60 * 1000;
|
|
863
|
+
// Clamp+normalize the inbound `published` FIRST and anchor the endTime bound to
|
|
864
|
+
// THAT, not the raw value: a far-future `published` ("9999-…") would otherwise
|
|
865
|
+
// push maxEndMs far into the future too and defeat this very expiry clamp.
|
|
866
|
+
const publishedAt = normalizeInboundTimestamp(object.published, now);
|
|
867
|
+
const publishedMs = Date.parse(publishedAt);
|
|
868
|
+
const maxEndMs =
|
|
869
|
+
(Number.isNaN(publishedMs) ? Date.now() : publishedMs) +
|
|
870
|
+
STORY_MAX_LIFETIME_MS;
|
|
871
|
+
const requestedEndMs = object.endTime ? Date.parse(object.endTime) : NaN;
|
|
872
|
+
const endTime = new Date(
|
|
873
|
+
Number.isNaN(requestedEndMs)
|
|
874
|
+
? maxEndMs
|
|
875
|
+
: Math.min(requestedEndMs, maxEndMs),
|
|
876
|
+
).toISOString();
|
|
877
|
+
|
|
878
|
+
// The early existence check above is best-effort (TOCTOU): two isolates
|
|
879
|
+
// racing the same cold story can both pass it. `onConflictDoNothing` keeps
|
|
880
|
+
// that race insert-safe, and gating follow-on side effects on the returned
|
|
881
|
+
// row mirrors the duplicate guard in handleCreate.
|
|
882
|
+
// Bound the serialized story data. Caption is capped and overlays are
|
|
883
|
+
// count-limited above, but per-overlay padding could still inflate it; if the
|
|
884
|
+
// blob exceeds the attachments cap, drop the (decorative) overlays so the core
|
|
885
|
+
// attachment + caption still persist within bounds.
|
|
886
|
+
let storyDataJson = JSON.stringify(attachmentData);
|
|
887
|
+
if (storyDataJson.length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
888
|
+
storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const inserted = await db
|
|
892
|
+
.insert(objects)
|
|
893
|
+
.values({
|
|
894
|
+
apId: objectId,
|
|
895
|
+
type: "Story",
|
|
896
|
+
attributedTo: actor,
|
|
897
|
+
content: "",
|
|
898
|
+
attachmentsJson: storyDataJson,
|
|
899
|
+
endTime,
|
|
900
|
+
published: publishedAt,
|
|
901
|
+
isLocal: 0,
|
|
902
|
+
})
|
|
903
|
+
.onConflictDoNothing()
|
|
904
|
+
.returning()
|
|
905
|
+
.get();
|
|
906
|
+
|
|
907
|
+
if (!inserted) return; // duplicate
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
// Delete handler
|
|
912
|
+
// ---------------------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Tombstone a remote actor locally in response to a verified inbound
|
|
916
|
+
* Delete(Actor). Mirrors the local /me/delete teardown for the federation-facing
|
|
917
|
+
* state we hold about a remote: reconcile LOCAL counterparts' follower/following
|
|
918
|
+
* counts, drop the follow edges in both directions, purge the actor cache, and
|
|
919
|
+
* cascade-delete the remote's cached content. All deletes are subquery-scoped
|
|
920
|
+
* (no spliced ids → D1-param-safe) and remote objects carry no R2 blobs (their
|
|
921
|
+
* media are remote URLs, not local uploads), so no media purge is needed.
|
|
922
|
+
*/
|
|
923
|
+
async function handleRemoteActorDelete(
|
|
924
|
+
c: ActivityContext,
|
|
925
|
+
actorId: string,
|
|
926
|
+
): Promise<void> {
|
|
927
|
+
const db = c.get("db");
|
|
928
|
+
|
|
929
|
+
// Counterpart count reconcile BEFORE dropping edges (mirrors actors.ts):
|
|
930
|
+
// everyone the deleted remote followed loses a follower; everyone who followed
|
|
931
|
+
// it loses a following. The subquery naturally scopes to LOCAL actors (remote
|
|
932
|
+
// actors have no `actors` row); gt(...,0) guards underflow.
|
|
933
|
+
await db
|
|
934
|
+
.update(actors)
|
|
935
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
936
|
+
.where(
|
|
937
|
+
and(
|
|
938
|
+
inArray(
|
|
939
|
+
actors.apId,
|
|
940
|
+
db
|
|
941
|
+
.select({ id: follows.followingApId })
|
|
942
|
+
.from(follows)
|
|
943
|
+
.where(eq(follows.followerApId, actorId)),
|
|
944
|
+
),
|
|
945
|
+
gt(actors.followerCount, 0),
|
|
946
|
+
),
|
|
947
|
+
);
|
|
948
|
+
await db
|
|
949
|
+
.update(actors)
|
|
950
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
951
|
+
.where(
|
|
952
|
+
and(
|
|
953
|
+
inArray(
|
|
954
|
+
actors.apId,
|
|
955
|
+
db
|
|
956
|
+
.select({ id: follows.followerApId })
|
|
957
|
+
.from(follows)
|
|
958
|
+
.where(eq(follows.followingApId, actorId)),
|
|
959
|
+
),
|
|
960
|
+
gt(actors.followingCount, 0),
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
await db
|
|
964
|
+
.delete(follows)
|
|
965
|
+
.where(
|
|
966
|
+
or(eq(follows.followerApId, actorId), eq(follows.followingApId, actorId)),
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
// Recompute the replyCount of any LOCAL parent the remote's cached objects
|
|
970
|
+
// replied to, counting only the replies that will REMAIN (not authored by the
|
|
971
|
+
// deleted remote), BEFORE the cascade removes them (mirrors actors.ts).
|
|
972
|
+
await db
|
|
973
|
+
.update(objects)
|
|
974
|
+
.set({
|
|
975
|
+
replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to <> ${actorId})`,
|
|
976
|
+
})
|
|
977
|
+
.where(
|
|
978
|
+
inArray(
|
|
979
|
+
objects.apId,
|
|
980
|
+
db
|
|
981
|
+
.select({ id: objects.inReplyTo })
|
|
982
|
+
.from(objects)
|
|
983
|
+
.where(
|
|
984
|
+
and(
|
|
985
|
+
eq(objects.attributedTo, actorId),
|
|
986
|
+
isNotNull(objects.inReplyTo),
|
|
987
|
+
),
|
|
988
|
+
),
|
|
989
|
+
),
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
// Reconcile the like/announce/share counters on OTHER objects the deleted
|
|
993
|
+
// remote INTERACTED with, BEFORE dropping its edges — mirrors the local
|
|
994
|
+
// account-delete griefing defense (actors.ts). A throwaway remote could ratchet
|
|
995
|
+
// a local post's like/announce/share counts then self-delete via a signed
|
|
996
|
+
// Delete(Person); without this those counts stay permanently inflated and the
|
|
997
|
+
// edge rows orphan (the object-scoped cascade below only reaps interactions ON
|
|
998
|
+
// the remote's OWN posts, not the ones it authored on others'). gt(...,0) guards
|
|
999
|
+
// underflow; the subquery scopes without splicing ids (D1 param ceiling).
|
|
1000
|
+
await db
|
|
1001
|
+
.update(objects)
|
|
1002
|
+
.set({ likeCount: sql`${objects.likeCount} - 1` })
|
|
1003
|
+
.where(
|
|
1004
|
+
and(
|
|
1005
|
+
inArray(
|
|
1006
|
+
objects.apId,
|
|
1007
|
+
db
|
|
1008
|
+
.select({ id: likes.objectApId })
|
|
1009
|
+
.from(likes)
|
|
1010
|
+
.where(eq(likes.actorApId, actorId)),
|
|
1011
|
+
),
|
|
1012
|
+
gt(objects.likeCount, 0),
|
|
1013
|
+
),
|
|
1014
|
+
);
|
|
1015
|
+
await db
|
|
1016
|
+
.update(objects)
|
|
1017
|
+
.set({ announceCount: sql`${objects.announceCount} - 1` })
|
|
1018
|
+
.where(
|
|
1019
|
+
and(
|
|
1020
|
+
inArray(
|
|
1021
|
+
objects.apId,
|
|
1022
|
+
db
|
|
1023
|
+
.select({ id: announces.objectApId })
|
|
1024
|
+
.from(announces)
|
|
1025
|
+
.where(eq(announces.actorApId, actorId)),
|
|
1026
|
+
),
|
|
1027
|
+
gt(objects.announceCount, 0),
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
await db
|
|
1031
|
+
.update(objects)
|
|
1032
|
+
.set({ shareCount: sql`${objects.shareCount} - 1` })
|
|
1033
|
+
.where(
|
|
1034
|
+
and(
|
|
1035
|
+
inArray(
|
|
1036
|
+
objects.apId,
|
|
1037
|
+
db
|
|
1038
|
+
.select({ id: storyShares.storyApId })
|
|
1039
|
+
.from(storyShares)
|
|
1040
|
+
.where(eq(storyShares.actorApId, actorId)),
|
|
1041
|
+
),
|
|
1042
|
+
gt(objects.shareCount, 0),
|
|
1043
|
+
),
|
|
1044
|
+
);
|
|
1045
|
+
// Delete the interaction edges the remote AUTHORED on OTHER objects.
|
|
1046
|
+
await db.delete(likes).where(eq(likes.actorApId, actorId));
|
|
1047
|
+
await db.delete(announces).where(eq(announces.actorApId, actorId));
|
|
1048
|
+
await db.delete(bookmarks).where(eq(bookmarks.actorApId, actorId));
|
|
1049
|
+
await db.delete(storyShares).where(eq(storyShares.actorApId, actorId));
|
|
1050
|
+
await db.delete(storyVotes).where(eq(storyVotes.actorApId, actorId));
|
|
1051
|
+
await db.delete(storyViews).where(eq(storyViews.actorApId, actorId));
|
|
1052
|
+
|
|
1053
|
+
// Cascade child rows keyed by the remote's authored objects (no FK cascade on
|
|
1054
|
+
// prod D1), then the objects themselves. A fresh subquery per statement avoids
|
|
1055
|
+
// shared-AST reuse.
|
|
1056
|
+
const remoteObjectIds = () =>
|
|
1057
|
+
db
|
|
1058
|
+
.select({ id: objects.apId })
|
|
1059
|
+
.from(objects)
|
|
1060
|
+
.where(eq(objects.attributedTo, actorId));
|
|
1061
|
+
await db.delete(likes).where(inArray(likes.objectApId, remoteObjectIds()));
|
|
1062
|
+
await db
|
|
1063
|
+
.delete(announces)
|
|
1064
|
+
.where(inArray(announces.objectApId, remoteObjectIds()));
|
|
1065
|
+
await db
|
|
1066
|
+
.delete(bookmarks)
|
|
1067
|
+
.where(inArray(bookmarks.objectApId, remoteObjectIds()));
|
|
1068
|
+
await db
|
|
1069
|
+
.delete(objectRecipients)
|
|
1070
|
+
.where(inArray(objectRecipients.objectApId, remoteObjectIds()));
|
|
1071
|
+
await db
|
|
1072
|
+
.delete(storyVotes)
|
|
1073
|
+
.where(inArray(storyVotes.storyApId, remoteObjectIds()));
|
|
1074
|
+
await db
|
|
1075
|
+
.delete(storyViews)
|
|
1076
|
+
.where(inArray(storyViews.storyApId, remoteObjectIds()));
|
|
1077
|
+
await db
|
|
1078
|
+
.delete(storyShares)
|
|
1079
|
+
.where(inArray(storyShares.storyApId, remoteObjectIds()));
|
|
1080
|
+
await db.delete(objects).where(eq(objects.attributedTo, actorId));
|
|
1081
|
+
|
|
1082
|
+
await db.delete(actorCache).where(eq(actorCache.apId, actorId));
|
|
1083
|
+
|
|
1084
|
+
log.info("Processed inbound Delete(actor)", {
|
|
1085
|
+
event: "ap.delete.actor",
|
|
1086
|
+
actor: actorId,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export async function handleDelete(c: ActivityContext, activity: Activity) {
|
|
1091
|
+
const db = c.get("db");
|
|
1092
|
+
const objectId = getActivityObjectId(activity);
|
|
1093
|
+
if (!objectId) return;
|
|
1094
|
+
|
|
1095
|
+
const actorId = typeof activity.actor === "string" ? activity.actor : null;
|
|
1096
|
+
if (!actorId) {
|
|
1097
|
+
log.warn("Delete activity missing actor", {
|
|
1098
|
+
event: "ap.delete.missing_actor",
|
|
1099
|
+
objectId,
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const delObj = await db
|
|
1105
|
+
.select({
|
|
1106
|
+
attributedTo: objects.attributedTo,
|
|
1107
|
+
type: objects.type,
|
|
1108
|
+
replyCount: objects.replyCount,
|
|
1109
|
+
inReplyTo: objects.inReplyTo,
|
|
1110
|
+
})
|
|
1111
|
+
.from(objects)
|
|
1112
|
+
.where(eq(objects.apId, objectId))
|
|
1113
|
+
.get();
|
|
1114
|
+
if (!delObj) {
|
|
1115
|
+
// Delete(Actor): a remote announcing its OWN account deletion addresses the
|
|
1116
|
+
// actor as the object (object === actor). Remote actors are never stored in
|
|
1117
|
+
// `objects` (they live in actorCache), so the per-object path above finds no
|
|
1118
|
+
// row. verifyAndParseInbox has already bound the signer to activity.actor
|
|
1119
|
+
// (same origin), so an object that equals the verified actor is owned by the
|
|
1120
|
+
// signer. Tombstone the remote locally so a stale profile + dangling follow
|
|
1121
|
+
// edge + cached content do not survive indefinitely.
|
|
1122
|
+
if (objectId === actorId) {
|
|
1123
|
+
await handleRemoteActorDelete(c, actorId);
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Verify actor owns the object before deleting
|
|
1129
|
+
if (delObj.attributedTo !== actorId) {
|
|
1130
|
+
log.warn("Delete rejected: actor does not own object", {
|
|
1131
|
+
event: "ap.delete.actor_ownership_mismatch",
|
|
1132
|
+
actor: actorId,
|
|
1133
|
+
objectId,
|
|
1134
|
+
ownedBy: delObj.attributedTo,
|
|
1135
|
+
});
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Delete every child row keyed by this object before the object row itself.
|
|
1140
|
+
// FK ON DELETE CASCADE is not reliably enforced on every runtime/connection
|
|
1141
|
+
// (D1 ignores PRAGMA foreign_keys), so cascade explicitly to avoid orphans.
|
|
1142
|
+
// Covers likes/announces/bookmarks/object_recipients/story_* in one place,
|
|
1143
|
+
// shared with the local DELETE /posts/:id path.
|
|
1144
|
+
const mediaKeys = await deleteObjectCascade(db, objectId, c.env.MEDIA);
|
|
1145
|
+
|
|
1146
|
+
// #3 (atomicity + idempotency): the object-row delete and the counter
|
|
1147
|
+
// decrements MUST commit together. Previously the row was deleted and the
|
|
1148
|
+
// counts decremented in SEPARATE awaits; under the claim/processed
|
|
1149
|
+
// re-dispatch model a crash between them left the row gone but the counts
|
|
1150
|
+
// un-decremented, and a peer retry early-returns on the absent row so the
|
|
1151
|
+
// decrements were SKIPPED → permanent postCount/replyCount drift. Co-commit
|
|
1152
|
+
// them in one atomic batch (the media cascade above is intentionally NOT
|
|
1153
|
+
// moved into the batch — it must run first while attachments_json is still
|
|
1154
|
+
// readable). Statement ordering inside the batch:
|
|
1155
|
+
// - postCount -1 runs BEFORE the delete, guarded by a correlated
|
|
1156
|
+
// EXISTS(object) subquery (so a duplicate Delete / retry on an
|
|
1157
|
+
// already-gone row is a no-op) plus a gt(postCount,0) underflow guard
|
|
1158
|
+
// (mirrors handleRemove).
|
|
1159
|
+
// - replyCount is RECOMPUTED from COUNT(*) of the remaining reply edge set
|
|
1160
|
+
// AFTER the delete (mirrors undoInteraction's object-counter recompute):
|
|
1161
|
+
// exact and idempotent, so a retry CONVERGES to the true reply count.
|
|
1162
|
+
const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${objectId})`;
|
|
1163
|
+
const author = delObj.attributedTo;
|
|
1164
|
+
const deleteObject = db.delete(objects).where(eq(objects.apId, objectId));
|
|
1165
|
+
const decPostCount = db
|
|
1166
|
+
.update(actors)
|
|
1167
|
+
.set({ postCount: sql`${actors.postCount} - 1` })
|
|
1168
|
+
.where(and(eq(actors.apId, author), gt(actors.postCount, 0), objectExists));
|
|
1169
|
+
|
|
1170
|
+
if (delObj.inReplyTo) {
|
|
1171
|
+
const parentId = delObj.inReplyTo;
|
|
1172
|
+
await runBatch(db, [
|
|
1173
|
+
decPostCount,
|
|
1174
|
+
deleteObject,
|
|
1175
|
+
db
|
|
1176
|
+
.update(objects)
|
|
1177
|
+
.set({
|
|
1178
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
1179
|
+
})
|
|
1180
|
+
.where(eq(objects.apId, parentId)),
|
|
1181
|
+
]);
|
|
1182
|
+
} else {
|
|
1183
|
+
await runBatch(db, [decPostCount, deleteObject]);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Irreversible R2 purge LAST — after the objects row is gone. On the queue-
|
|
1187
|
+
// backed inbox path a failure here is also self-healing: a Delete retry
|
|
1188
|
+
// re-runs, finds no media_uploads rows, and proceeds.
|
|
1189
|
+
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ---------------------------------------------------------------------------
|
|
1193
|
+
// Update handler
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
|
|
1196
|
+
export async function handleUpdate(
|
|
1197
|
+
c: ActivityContext,
|
|
1198
|
+
activity: Activity,
|
|
1199
|
+
actor: string,
|
|
1200
|
+
) {
|
|
1201
|
+
const db = c.get("db");
|
|
1202
|
+
const object = getActivityObject(activity);
|
|
1203
|
+
if (!object) return;
|
|
1204
|
+
|
|
1205
|
+
const objectId = object.id;
|
|
1206
|
+
if (!objectId) return;
|
|
1207
|
+
|
|
1208
|
+
// Update(Person/Service/Group) — an inbound actor-document update (remote
|
|
1209
|
+
// profile / avatar / public-key rotation). Apply it immediately by
|
|
1210
|
+
// re-fetching and upserting the actor through the same canonical actor-cache
|
|
1211
|
+
// path used by cacheRemoteActor, instead of waiting for the 24h actor-cache
|
|
1212
|
+
// TTL to expire. A signed actor may only update its own document, so the
|
|
1213
|
+
// updated object must be the actor itself (`object.id === activity.actor`,
|
|
1214
|
+
// mirroring the actor==object self-update contract). The remote document is
|
|
1215
|
+
// re-fetched from origin (never trusted from the wire) so a spoofed Update
|
|
1216
|
+
// body cannot poison the cache.
|
|
1217
|
+
if (isActorTypeUpdate(object.type) || objectId === actor) {
|
|
1218
|
+
if (objectId !== actor) {
|
|
1219
|
+
log.warn("Update(actor) rejected: object id does not match actor", {
|
|
1220
|
+
event: "ap.update.actor_self_mismatch",
|
|
1221
|
+
actor,
|
|
1222
|
+
objectId,
|
|
1223
|
+
});
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// Amplification guard: an inbound Update(actor) would otherwise trigger an
|
|
1227
|
+
// unconditional outbound re-fetch of the actor document on EVERY activity,
|
|
1228
|
+
// so a remote could flood us into hammering its origin (or a third party).
|
|
1229
|
+
// Skip the re-fetch when the cached row was fetched within a short cooldown
|
|
1230
|
+
// window; the normal actor-cache TTL refresh still picks up later changes.
|
|
1231
|
+
const cached = await db
|
|
1232
|
+
.select({ lastFetchedAt: actorCache.lastFetchedAt })
|
|
1233
|
+
.from(actorCache)
|
|
1234
|
+
.where(eq(actorCache.apId, objectId))
|
|
1235
|
+
.get();
|
|
1236
|
+
if (cached?.lastFetchedAt) {
|
|
1237
|
+
const age = Date.now() - new Date(cached.lastFetchedAt).getTime();
|
|
1238
|
+
if (
|
|
1239
|
+
Number.isFinite(age) &&
|
|
1240
|
+
age >= 0 &&
|
|
1241
|
+
age < ACTOR_UPDATE_REFETCH_COOLDOWN_MS
|
|
1242
|
+
) {
|
|
1243
|
+
log.debug("Update(actor) re-fetch skipped: within cooldown", {
|
|
1244
|
+
event: "ap.update.actor_refetch_cooldown",
|
|
1245
|
+
actor: objectId,
|
|
1246
|
+
ageMs: age,
|
|
1247
|
+
});
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
await refreshActorCache(db, objectId);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const existing = await db
|
|
1256
|
+
.select({ attributedTo: objects.attributedTo })
|
|
1257
|
+
.from(objects)
|
|
1258
|
+
.where(eq(objects.apId, objectId))
|
|
1259
|
+
.get();
|
|
1260
|
+
if (!existing || existing.attributedTo !== actor) return;
|
|
1261
|
+
|
|
1262
|
+
// Update object content
|
|
1263
|
+
if (typeIncludes(object.type, "Note")) {
|
|
1264
|
+
const attachments = object.attachment
|
|
1265
|
+
? JSON.stringify(object.attachment)
|
|
1266
|
+
: undefined;
|
|
1267
|
+
await db
|
|
1268
|
+
.update(objects)
|
|
1269
|
+
.set({
|
|
1270
|
+
content:
|
|
1271
|
+
typeof object.content === "string" && object.content
|
|
1272
|
+
? truncate(object.content, MAX_POST_CONTENT_LENGTH)
|
|
1273
|
+
: undefined,
|
|
1274
|
+
summary:
|
|
1275
|
+
typeof object.summary === "string" && object.summary
|
|
1276
|
+
? truncate(object.summary, MAX_POST_SUMMARY_LENGTH)
|
|
1277
|
+
: undefined,
|
|
1278
|
+
attachmentsJson: attachments
|
|
1279
|
+
? boundAttachmentsJson(attachments)
|
|
1280
|
+
: undefined,
|
|
1281
|
+
updated: new Date().toISOString(),
|
|
1282
|
+
})
|
|
1283
|
+
.where(eq(objects.apId, objectId));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ---------------------------------------------------------------------------
|
|
1288
|
+
// Move handler (account migration)
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
export async function handleMove(
|
|
1292
|
+
c: ActivityContext,
|
|
1293
|
+
activity: Activity,
|
|
1294
|
+
actor: string,
|
|
1295
|
+
) {
|
|
1296
|
+
const db = c.get("db");
|
|
1297
|
+
const oldActorApId = getActivityObjectId(activity);
|
|
1298
|
+
const newActorApId = getActivityTargetId(activity);
|
|
1299
|
+
if (!oldActorApId || !newActorApId) return;
|
|
1300
|
+
|
|
1301
|
+
// Only accept self-move. Signature verification already ensures the request is signed,
|
|
1302
|
+
// but we also require Move.object to match Move.actor (defense-in-depth).
|
|
1303
|
+
if (oldActorApId !== actor) return;
|
|
1304
|
+
if (oldActorApId === newActorApId) return;
|
|
1305
|
+
|
|
1306
|
+
if (!isSafeRemoteUrl(newActorApId)) {
|
|
1307
|
+
log.warn("Blocked unsafe Move target", {
|
|
1308
|
+
event: "ap.move.unsafe_target",
|
|
1309
|
+
newActor: newActorApId,
|
|
1310
|
+
oldActor: oldActorApId,
|
|
1311
|
+
});
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// SECURITY (account-migration follow-graph hijack): a signed Move only proves
|
|
1316
|
+
// the OLD actor consents to move; it does NOT prove the destination is the same
|
|
1317
|
+
// person. Without verifying the destination's `alsoKnownAs` back-reference, a
|
|
1318
|
+
// remote actor that accumulated local followers could redirect them all to an
|
|
1319
|
+
// arbitrary unconsenting account (follower-stealing). Require the standard
|
|
1320
|
+
// Mastodon Move guard: the destination actor document must list the old actor
|
|
1321
|
+
// in `alsoKnownAs`. Fails closed.
|
|
1322
|
+
if (
|
|
1323
|
+
!(await destinationDeclaresAlias(
|
|
1324
|
+
newActorApId,
|
|
1325
|
+
oldActorApId,
|
|
1326
|
+
(await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
1327
|
+
))
|
|
1328
|
+
) {
|
|
1329
|
+
log.warn("Blocked Move without alsoKnownAs back-reference", {
|
|
1330
|
+
event: "ap.move.unverified_alias",
|
|
1331
|
+
newActor: newActorApId,
|
|
1332
|
+
oldActor: oldActorApId,
|
|
1333
|
+
});
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Refresh/cache the new actor document (best-effort).
|
|
1338
|
+
await refreshActorCache(db, newActorApId);
|
|
1339
|
+
|
|
1340
|
+
// Rewrite follow graph references from old -> new in batches.
|
|
1341
|
+
const followerRows = await db
|
|
1342
|
+
.select({
|
|
1343
|
+
followingApId: follows.followingApId,
|
|
1344
|
+
status: follows.status,
|
|
1345
|
+
activityApId: follows.activityApId,
|
|
1346
|
+
createdAt: follows.createdAt,
|
|
1347
|
+
acceptedAt: follows.acceptedAt,
|
|
1348
|
+
})
|
|
1349
|
+
.from(follows)
|
|
1350
|
+
.where(eq(follows.followerApId, oldActorApId));
|
|
1351
|
+
|
|
1352
|
+
const followingRows = await db
|
|
1353
|
+
.select({
|
|
1354
|
+
followerApId: follows.followerApId,
|
|
1355
|
+
status: follows.status,
|
|
1356
|
+
activityApId: follows.activityApId,
|
|
1357
|
+
createdAt: follows.createdAt,
|
|
1358
|
+
acceptedAt: follows.acceptedAt,
|
|
1359
|
+
})
|
|
1360
|
+
.from(follows)
|
|
1361
|
+
.where(eq(follows.followingApId, oldActorApId));
|
|
1362
|
+
|
|
1363
|
+
const followerTargets = followerRows.map((row) => row.followingApId);
|
|
1364
|
+
const followingSources = followingRows.map((row) => row.followerApId);
|
|
1365
|
+
|
|
1366
|
+
const existingFollowerPairs =
|
|
1367
|
+
followerTargets.length > 0
|
|
1368
|
+
? await db
|
|
1369
|
+
.select({ followingApId: follows.followingApId })
|
|
1370
|
+
.from(follows)
|
|
1371
|
+
.where(
|
|
1372
|
+
and(
|
|
1373
|
+
eq(follows.followerApId, newActorApId),
|
|
1374
|
+
// Subquery, not `inArray(followerTargets)`: the old actor's follow
|
|
1375
|
+
// graph can exceed D1's 100-bound-parameter ceiling. Same set as
|
|
1376
|
+
// followerTargets (the old actor's followees).
|
|
1377
|
+
inArray(
|
|
1378
|
+
follows.followingApId,
|
|
1379
|
+
db
|
|
1380
|
+
.select({ id: follows.followingApId })
|
|
1381
|
+
.from(follows)
|
|
1382
|
+
.where(eq(follows.followerApId, oldActorApId)),
|
|
1383
|
+
),
|
|
1384
|
+
),
|
|
1385
|
+
)
|
|
1386
|
+
: [];
|
|
1387
|
+
const existingFollowingPairs =
|
|
1388
|
+
followingSources.length > 0
|
|
1389
|
+
? await db
|
|
1390
|
+
.select({ followerApId: follows.followerApId })
|
|
1391
|
+
.from(follows)
|
|
1392
|
+
.where(
|
|
1393
|
+
and(
|
|
1394
|
+
// Subquery, not `inArray(followingSources)`: the old actor's
|
|
1395
|
+
// follower graph can exceed D1's 100-bound-parameter ceiling. Same
|
|
1396
|
+
// set as followingSources (the old actor's followers).
|
|
1397
|
+
inArray(
|
|
1398
|
+
follows.followerApId,
|
|
1399
|
+
db
|
|
1400
|
+
.select({ id: follows.followerApId })
|
|
1401
|
+
.from(follows)
|
|
1402
|
+
.where(eq(follows.followingApId, oldActorApId)),
|
|
1403
|
+
),
|
|
1404
|
+
eq(follows.followingApId, newActorApId),
|
|
1405
|
+
),
|
|
1406
|
+
)
|
|
1407
|
+
: [];
|
|
1408
|
+
|
|
1409
|
+
const existingFollowerTargetSet = new Set(
|
|
1410
|
+
existingFollowerPairs.map((row) => row.followingApId),
|
|
1411
|
+
);
|
|
1412
|
+
const existingFollowingSourceSet = new Set(
|
|
1413
|
+
existingFollowingPairs.map((row) => row.followerApId),
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1416
|
+
// Drop self-edges in addition to the existing-pair dedup: if the old and new
|
|
1417
|
+
// actor were already connected (old followed/was-followed-by new, or vice
|
|
1418
|
+
// versa), rewriting the endpoint to the new actor would produce a row where
|
|
1419
|
+
// followerApId === followingApId (a self-follow). Filter those out so the
|
|
1420
|
+
// migration never materializes a self-follow.
|
|
1421
|
+
const followerRewrites = followerRows
|
|
1422
|
+
.filter(
|
|
1423
|
+
(row) =>
|
|
1424
|
+
!existingFollowerTargetSet.has(row.followingApId) &&
|
|
1425
|
+
row.followingApId !== newActorApId,
|
|
1426
|
+
)
|
|
1427
|
+
.map((row) => ({
|
|
1428
|
+
followerApId: newActorApId,
|
|
1429
|
+
followingApId: row.followingApId,
|
|
1430
|
+
status: row.status,
|
|
1431
|
+
activityApId: row.activityApId,
|
|
1432
|
+
createdAt: row.createdAt,
|
|
1433
|
+
acceptedAt: row.acceptedAt,
|
|
1434
|
+
}));
|
|
1435
|
+
// For followers that are LOCAL to this instance, a bare edge rewrite is not
|
|
1436
|
+
// enough: the destination server has no record of the follow, so it would
|
|
1437
|
+
// never deliver the migrated account's posts (the local user's following list
|
|
1438
|
+
// would point at the new actor but silently receive nothing). Re-issue a
|
|
1439
|
+
// fresh, *pending* Follow to the new actor and enqueue outbound delivery —
|
|
1440
|
+
// the standard Mastodon "follow the move target on the user's behalf"
|
|
1441
|
+
// behavior; the destination's Accept flips it to accepted. Remote followers
|
|
1442
|
+
// are left as a plain edge rewrite: re-establishing their follow is their own
|
|
1443
|
+
// server's responsibility.
|
|
1444
|
+
const baseUrl = c.env.APP_URL;
|
|
1445
|
+
const localReFollows: { followerApId: string; followId: string }[] = [];
|
|
1446
|
+
const followingRewrites = followingRows
|
|
1447
|
+
.filter(
|
|
1448
|
+
(row) =>
|
|
1449
|
+
!existingFollowingSourceSet.has(row.followerApId) &&
|
|
1450
|
+
row.followerApId !== newActorApId,
|
|
1451
|
+
)
|
|
1452
|
+
.map((row) => {
|
|
1453
|
+
if (isLocal(row.followerApId, baseUrl)) {
|
|
1454
|
+
const followId = activityApId(baseUrl, generateId());
|
|
1455
|
+
localReFollows.push({ followerApId: row.followerApId, followId });
|
|
1456
|
+
return {
|
|
1457
|
+
followerApId: row.followerApId,
|
|
1458
|
+
followingApId: newActorApId,
|
|
1459
|
+
status: "pending",
|
|
1460
|
+
activityApId: followId,
|
|
1461
|
+
createdAt: row.createdAt,
|
|
1462
|
+
acceptedAt: null,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
return {
|
|
1466
|
+
followerApId: row.followerApId,
|
|
1467
|
+
followingApId: newActorApId,
|
|
1468
|
+
status: row.status,
|
|
1469
|
+
activityApId: row.activityApId,
|
|
1470
|
+
createdAt: row.createdAt,
|
|
1471
|
+
acceptedAt: row.acceptedAt,
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// LOCAL followers whose ACCEPTED edge to the old actor we are about to delete.
|
|
1476
|
+
// Each such edge was counted in that follower's followingCount; the re-issued
|
|
1477
|
+
// Follow to the new actor is created PENDING (uncounted) and only re-adds the
|
|
1478
|
+
// +1 when the destination Accepts (handleAccept). So the old +1 must be removed
|
|
1479
|
+
// now, otherwise the eventual Accept stacks a second +1 on the never-removed
|
|
1480
|
+
// old count → a permanent over-count of 1 per migrated follow. Decrementing at
|
|
1481
|
+
// delete time is correct in every Accept-timing case: during the pending window
|
|
1482
|
+
// the follower counts 0 of this relationship (right — it is pending), after the
|
|
1483
|
+
// Accept it is back to 1, and if the Accept never arrives it stays decremented
|
|
1484
|
+
// (right — the edge is perpetually pending). Remote followers' counts are not
|
|
1485
|
+
// ours to manage; only local followingCount is authoritative here.
|
|
1486
|
+
const localAcceptedFollowerApIds = Array.from(
|
|
1487
|
+
new Set(
|
|
1488
|
+
followingRows
|
|
1489
|
+
.filter(
|
|
1490
|
+
(row) =>
|
|
1491
|
+
row.status === "accepted" && isLocal(row.followerApId, baseUrl),
|
|
1492
|
+
)
|
|
1493
|
+
.map((row) => row.followerApId),
|
|
1494
|
+
),
|
|
1495
|
+
);
|
|
1496
|
+
|
|
1497
|
+
// Symmetric to the above, on the FOLLOWEE side: the old actor's ACCEPTED follow
|
|
1498
|
+
// of a LOCAL actor L incremented L.followerCount (handleFollow). When the
|
|
1499
|
+
// (old→L) rewrite is DROPPED as a duplicate (the NEW actor already follows L,
|
|
1500
|
+
// i.e. L ∈ existingFollowerTargetSet) or as a self-edge (L === newActor), the
|
|
1501
|
+
// delete below still removes (old→L) but NO rewrite re-adds an (new→L) edge for
|
|
1502
|
+
// it — so L would keep a permanent +1 over-count. Decrement those dropped,
|
|
1503
|
+
// accepted, local followees' followerCount once (the non-dropped case is
|
|
1504
|
+
// count-preserving: delete old→L + insert new→L). gt(>0) guards underflow.
|
|
1505
|
+
const droppedAcceptedLocalFolloweeApIds = Array.from(
|
|
1506
|
+
new Set(
|
|
1507
|
+
followerRows
|
|
1508
|
+
.filter(
|
|
1509
|
+
(row) =>
|
|
1510
|
+
row.status === "accepted" &&
|
|
1511
|
+
isLocal(row.followingApId, baseUrl) &&
|
|
1512
|
+
(existingFollowerTargetSet.has(row.followingApId) ||
|
|
1513
|
+
row.followingApId === newActorApId),
|
|
1514
|
+
)
|
|
1515
|
+
.map((row) => row.followingApId),
|
|
1516
|
+
),
|
|
1517
|
+
);
|
|
1518
|
+
|
|
1519
|
+
// Co-commit the four edge mutations + the per-follower followingCount
|
|
1520
|
+
// decrements in ONE atomic batch. D1 has no interactive transactions, and the
|
|
1521
|
+
// OLD sequential form was non-convergent: a crash between "delete old edges"
|
|
1522
|
+
// and "decrement" left the old edges gone, so a re-dispatch (the row is still
|
|
1523
|
+
// processed=0) re-read EMPTY follower/following rows, skipped the decrement,
|
|
1524
|
+
// and left every migrated local follower's followingCount permanently +1 over.
|
|
1525
|
+
// Batching makes delete+decrement all-or-nothing: a crash before commit changes
|
|
1526
|
+
// nothing (retry re-runs cleanly from the still-present old edges); a crash
|
|
1527
|
+
// after commit re-reads no old edges (the batch is then a no-op) → the
|
|
1528
|
+
// decrement is applied exactly once.
|
|
1529
|
+
const moveOps = [];
|
|
1530
|
+
if (followerRewrites.length > 0) {
|
|
1531
|
+
moveOps.push(db.insert(follows).values(followerRewrites));
|
|
1532
|
+
}
|
|
1533
|
+
if (followerRows.length > 0) {
|
|
1534
|
+
moveOps.push(
|
|
1535
|
+
db.delete(follows).where(eq(follows.followerApId, oldActorApId)),
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
if (followingRewrites.length > 0) {
|
|
1539
|
+
moveOps.push(db.insert(follows).values(followingRewrites));
|
|
1540
|
+
}
|
|
1541
|
+
if (followingRows.length > 0) {
|
|
1542
|
+
moveOps.push(
|
|
1543
|
+
db.delete(follows).where(eq(follows.followingApId, oldActorApId)),
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
for (const followerApId of localAcceptedFollowerApIds) {
|
|
1547
|
+
moveOps.push(
|
|
1548
|
+
db
|
|
1549
|
+
.update(actors)
|
|
1550
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
1551
|
+
.where(
|
|
1552
|
+
and(eq(actors.apId, followerApId), gt(actors.followingCount, 0)),
|
|
1553
|
+
),
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
for (const followeeApId of droppedAcceptedLocalFolloweeApIds) {
|
|
1557
|
+
moveOps.push(
|
|
1558
|
+
db
|
|
1559
|
+
.update(actors)
|
|
1560
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
1561
|
+
.where(and(eq(actors.apId, followeeApId), gt(actors.followerCount, 0))),
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
if (moveOps.length > 0) {
|
|
1565
|
+
await runBatch(db, moveOps as unknown as Parameters<typeof runBatch>[1]);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Record + deliver the outbound Follow activities for migrated local
|
|
1569
|
+
// followers so the destination server registers them as followers and starts
|
|
1570
|
+
// delivering. Best-effort per follower: a delivery enqueue failure must not
|
|
1571
|
+
// abort the rest of the migration (the follow row is already pending and will
|
|
1572
|
+
// simply lack delivery until retried).
|
|
1573
|
+
for (const { followerApId, followId } of localReFollows) {
|
|
1574
|
+
const followActivity = {
|
|
1575
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
1576
|
+
id: followId,
|
|
1577
|
+
type: "Follow",
|
|
1578
|
+
actor: followerApId,
|
|
1579
|
+
object: newActorApId,
|
|
1580
|
+
};
|
|
1581
|
+
try {
|
|
1582
|
+
await db.insert(activities).values({
|
|
1583
|
+
apId: followId,
|
|
1584
|
+
type: "Follow",
|
|
1585
|
+
actorApId: followerApId,
|
|
1586
|
+
objectApId: newActorApId,
|
|
1587
|
+
rawJson: JSON.stringify(followActivity),
|
|
1588
|
+
direction: "outbound",
|
|
1589
|
+
});
|
|
1590
|
+
await enqueueDeliveryToActor(c.env, followId, newActorApId);
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
log.warn("Failed to issue migration re-follow to move target", {
|
|
1593
|
+
event: "ap.move.refollow_failed",
|
|
1594
|
+
follower: followerApId,
|
|
1595
|
+
newActor: newActorApId,
|
|
1596
|
+
error: e,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// ---------------------------------------------------------------------------
|
|
1603
|
+
// Internal helpers
|
|
1604
|
+
// ---------------------------------------------------------------------------
|
|
1605
|
+
|
|
1606
|
+
function getActivityTargetId(activity: Activity): string | null {
|
|
1607
|
+
const target = activity.target;
|
|
1608
|
+
if (!target) return null;
|
|
1609
|
+
if (typeof target === "string") return target;
|
|
1610
|
+
return target.id || null;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** Fetch a remote actor document and cache it locally. Best-effort (errors are logged, not thrown). */
|
|
1614
|
+
async function refreshActorCache(
|
|
1615
|
+
db: Database,
|
|
1616
|
+
actorApIdValue: string,
|
|
1617
|
+
): Promise<void> {
|
|
1618
|
+
const result = await fetchAndUpsertActorCache(db, actorApIdValue, {
|
|
1619
|
+
timeout: 15000,
|
|
1620
|
+
mode: "upsert",
|
|
1621
|
+
// Sign as the instance actor so a secure-mode remote serves its doc.
|
|
1622
|
+
signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
1623
|
+
});
|
|
1624
|
+
if (!result.ok && result.reason === "fetch_failed") {
|
|
1625
|
+
// Shared by Move (refresh the migration target) and Update(actor)
|
|
1626
|
+
// (apply a remote profile / key rotation immediately). Best-effort: a
|
|
1627
|
+
// failed refresh simply leaves the existing cache row in place until the
|
|
1628
|
+
// normal TTL refresh, so it is logged rather than thrown.
|
|
1629
|
+
log.warn("Failed to refresh remote actor cache", {
|
|
1630
|
+
event: "ap.actor.cache_refresh_failed",
|
|
1631
|
+
actor: actorApIdValue,
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|