@takosjp/yurucommu-core 3.4.3 → 3.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Database } from "../../../../db/index.ts";
|
|
2
|
+
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
|
2
3
|
import {
|
|
3
4
|
and,
|
|
4
5
|
count,
|
|
@@ -7,72 +8,100 @@ import {
|
|
|
7
8
|
inArray,
|
|
8
9
|
isNotNull,
|
|
9
10
|
isNull,
|
|
11
|
+
notExists,
|
|
10
12
|
or,
|
|
11
13
|
sql,
|
|
12
14
|
} from "drizzle-orm";
|
|
13
|
-
import type { BatchItem } from "drizzle-orm/batch";
|
|
14
15
|
import {
|
|
15
16
|
activities,
|
|
16
17
|
actorCache,
|
|
17
18
|
actors,
|
|
18
19
|
announces,
|
|
19
|
-
blocks,
|
|
20
20
|
bookmarks,
|
|
21
21
|
communities,
|
|
22
|
+
deliveryEndpointRecipients,
|
|
23
|
+
deliveryQueue,
|
|
24
|
+
deliveryResolutions,
|
|
22
25
|
follows,
|
|
23
26
|
inbox as inboxTable,
|
|
24
27
|
likes,
|
|
25
28
|
objectRecipients,
|
|
26
29
|
objects,
|
|
30
|
+
remoteActorFetchFailures,
|
|
31
|
+
remoteActorTombstones,
|
|
27
32
|
storyShares,
|
|
28
33
|
storyViews,
|
|
29
34
|
storyVotes,
|
|
30
35
|
} from "../../../../db/index.ts";
|
|
36
|
+
import { insertMany, runBatch } from "../../../../db/d1-write.ts";
|
|
37
|
+
import {
|
|
38
|
+
addressesPublic,
|
|
39
|
+
collectBoundedInboundAddresses,
|
|
40
|
+
} from "../inbound-addressing.ts";
|
|
31
41
|
import { upsertActivityAndNotify } from "./inbox-shared-helpers.ts";
|
|
32
42
|
import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
|
|
43
|
+
import { resolveInboundCommunityScope } from "./inbound-community-scope.ts";
|
|
44
|
+
import {
|
|
45
|
+
MAX_INBOUND_OBJECT_ID_LENGTH,
|
|
46
|
+
validateInboundObjectIdentity,
|
|
47
|
+
} from "./inbound-object-identity.ts";
|
|
48
|
+
import { validateInboundReplyTarget } from "./inbound-reply-target.ts";
|
|
49
|
+
import {
|
|
50
|
+
buildInboundStoryCreateProjection,
|
|
51
|
+
buildInboundStoryUpdateProjection,
|
|
52
|
+
declaresStoryAddressing,
|
|
53
|
+
hasStoryProjectionUpdate,
|
|
54
|
+
normalizeInboundStoryCreateEndTime,
|
|
55
|
+
normalizeInboundStoryUpdateEndTime,
|
|
56
|
+
storyAddressedCollections,
|
|
57
|
+
} from "./inbound-story-projection.ts";
|
|
33
58
|
import {
|
|
34
59
|
deleteObjectCascade,
|
|
35
60
|
purgeMediaBlobs,
|
|
36
61
|
} from "../../posts/delete-cascade.ts";
|
|
37
62
|
import {
|
|
38
|
-
boundAttachmentsJson,
|
|
39
63
|
boundInboundContent,
|
|
64
|
+
boundInboundNoteAttachmentsJson,
|
|
40
65
|
boundInboundSummary,
|
|
41
|
-
|
|
42
|
-
MAX_POST_CONTENT_LENGTH,
|
|
43
|
-
MAX_POST_SUMMARY_LENGTH,
|
|
44
|
-
truncate,
|
|
66
|
+
boundInboundTagsJson,
|
|
45
67
|
} from "../../posts/transformers.ts";
|
|
46
68
|
import {
|
|
47
69
|
activityApId,
|
|
48
70
|
generateId,
|
|
49
|
-
getDomain,
|
|
50
71
|
isLocal,
|
|
51
72
|
isSafeRemoteUrl,
|
|
52
|
-
objectApId,
|
|
53
73
|
} from "../../../federation-helpers.ts";
|
|
54
74
|
import { getConversationId } from "../../dm/query-helpers.ts";
|
|
55
75
|
import {
|
|
56
76
|
fetchAndUpsertActorCache,
|
|
57
77
|
getInstanceFetchSignerByDb,
|
|
58
78
|
} from "../../../lib/activitypub-actor-cache.ts";
|
|
79
|
+
import {
|
|
80
|
+
isSameActivityPubActor,
|
|
81
|
+
normalizeActivityPubActorId,
|
|
82
|
+
} from "../../../lib/activitypub-actor-identity.ts";
|
|
83
|
+
import { activityPubActorIdentityMatchesSql } from "../../../lib/activitypub-actor-identity-sql.ts";
|
|
59
84
|
import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
|
|
60
85
|
import { signRequest } from "../../../lib/ap-signing.ts";
|
|
61
|
-
import {
|
|
62
|
-
|
|
86
|
+
import {
|
|
87
|
+
destinationDeclaresAlias,
|
|
88
|
+
enqueuePersistedMoveRefollows,
|
|
89
|
+
moveRefollowPrefix,
|
|
90
|
+
rewriteMovedFollowGraph,
|
|
91
|
+
} from "../../../lib/account-migration.ts";
|
|
63
92
|
import { chunkForInClause } from "../../../lib/chunk.ts";
|
|
64
93
|
import {
|
|
65
|
-
|
|
94
|
+
actorSuppressesInteractionFrom,
|
|
66
95
|
canViewerReadObjectFull,
|
|
67
96
|
} from "../../../lib/post-visibility.ts";
|
|
68
97
|
import { logger } from "../../../lib/logger.ts";
|
|
98
|
+
import { anyOwnerSuppressesInboundActor } from "../../../lib/personal-actor-moderation.ts";
|
|
69
99
|
import {
|
|
70
100
|
type Activity,
|
|
71
101
|
type ActivityContext,
|
|
72
102
|
type ActivityObject,
|
|
73
103
|
getActivityObject,
|
|
74
104
|
getActivityObjectId,
|
|
75
|
-
type StoryOverlay,
|
|
76
105
|
typeIncludes,
|
|
77
106
|
} from "../inbox-types.ts";
|
|
78
107
|
|
|
@@ -83,29 +112,6 @@ type ActorRow = typeof actors.$inferSelect;
|
|
|
83
112
|
// normalizeInboundTimestamp now lives in ./inbound-timestamp.ts (shared with the
|
|
84
113
|
// federated group-chat path) — imported at the top of this file.
|
|
85
114
|
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch` and
|
|
88
|
-
// the inbox-interaction / inbox-shared helper). D1 has no interactive
|
|
89
|
-
// transactions, but both the D1 and libsql drivers expose `db.batch([...])`,
|
|
90
|
-
// which commits a list of prepared statements atomically. The shared
|
|
91
|
-
// `Database` union aliases the abstract `BaseSQLiteDatabase` base (which does
|
|
92
|
-
// not surface `batch`), so we narrow to the concrete batch surface here.
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
type BatchStatement = BatchItem<"sqlite">;
|
|
96
|
-
interface BatchableDb {
|
|
97
|
-
batch(
|
|
98
|
-
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
99
|
-
): Promise<unknown>;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function runBatch(
|
|
103
|
-
db: Database,
|
|
104
|
-
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
105
|
-
): Promise<void> {
|
|
106
|
-
await (db as unknown as BatchableDb).batch(statements);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
115
|
// Federation blocklist enforcement lives centrally in
|
|
110
116
|
// `verifyAndParseInbox` (routes/activitypub/inbox.ts): every inbound
|
|
111
117
|
// activity is gated once there before any handler runs, so the per-handler
|
|
@@ -120,6 +126,14 @@ function isStoryType(type: string | string[] | undefined): boolean {
|
|
|
120
126
|
return Array.isArray(type) ? type.includes("Story") : type === "Story";
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
/** Single-user instance policy: any local owner block/mute suppresses content writes. */
|
|
130
|
+
async function ownerSuppressesInboundActor(
|
|
131
|
+
db: Database,
|
|
132
|
+
actorApId: string,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
return anyOwnerSuppressesInboundActor(db, actorApId);
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
// The actor object types whose inbound Update represents a remote
|
|
124
138
|
// profile / avatar / public-key change that should refresh the actor cache.
|
|
125
139
|
const ACTOR_OBJECT_TYPES = new Set([
|
|
@@ -143,18 +157,6 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
|
|
|
143
157
|
: ACTOR_OBJECT_TYPES.has(type);
|
|
144
158
|
}
|
|
145
159
|
|
|
146
|
-
// The ActivityStreams public-collection magic value, including the legacy
|
|
147
|
-
// short forms some implementations still emit.
|
|
148
|
-
export const PUBLIC_COLLECTION = new Set([
|
|
149
|
-
"https://www.w3.org/ns/activitystreams#Public",
|
|
150
|
-
"as:Public",
|
|
151
|
-
"Public",
|
|
152
|
-
]);
|
|
153
|
-
|
|
154
|
-
export function addressesPublic(addresses: string[]): boolean {
|
|
155
|
-
return addresses.some((a) => PUBLIC_COLLECTION.has(a));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
160
|
// A note addressed to a followers collection (the author's `<actor>/followers`)
|
|
159
161
|
// and NOT to Public is a followers-only post. We match any `/followers`
|
|
160
162
|
// collection by suffix (mirrors isDirectNote), which covers the author's
|
|
@@ -163,29 +165,115 @@ export function addressesFollowers(addresses: string[]): boolean {
|
|
|
163
165
|
return addresses.some((a) => a.endsWith("/followers"));
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
type NoteAddressing = {
|
|
169
|
+
readonly to: string[];
|
|
170
|
+
readonly cc: string[];
|
|
171
|
+
readonly bto: string[];
|
|
172
|
+
readonly bcc: string[];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
type AddressingSource = Pick<Activity, "to" | "cc" | "bto" | "bcc">;
|
|
176
|
+
|
|
177
|
+
function declaresAddressing(source: AddressingSource): boolean {
|
|
178
|
+
return (
|
|
179
|
+
source.to !== undefined ||
|
|
180
|
+
source.cc !== undefined ||
|
|
181
|
+
source.bto !== undefined ||
|
|
182
|
+
source.bcc !== undefined
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function noteAddressing(source: AddressingSource): NoteAddressing {
|
|
187
|
+
return {
|
|
188
|
+
to: addressList(source.to),
|
|
189
|
+
cc: addressList(source.cc),
|
|
190
|
+
bto: addressList(source.bto),
|
|
191
|
+
bcc: addressList(source.bcc),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the Note reach carried by a Create. The embedded object is the
|
|
197
|
+
* durable object projection when it declares any addressing field (including
|
|
198
|
+
* an explicit empty array); peers that put all addressing on the Create
|
|
199
|
+
* envelope remain compatible through the fallback.
|
|
200
|
+
*/
|
|
201
|
+
function createNoteAddressing(
|
|
202
|
+
activity: Activity,
|
|
203
|
+
object: ActivityObject,
|
|
204
|
+
): NoteAddressing {
|
|
205
|
+
return noteAddressing(declaresAddressing(object) ? object : activity);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function allNoteAddresses(addressing: NoteAddressing): string[] {
|
|
209
|
+
return [
|
|
210
|
+
...addressing.to,
|
|
211
|
+
...addressing.cc,
|
|
212
|
+
...addressing.bto,
|
|
213
|
+
...addressing.bcc,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Bounded actor/specific-object recipients, excluding collection reach. These
|
|
219
|
+
* become indexed object_recipients authority. `bto`/`bcc` values never enter
|
|
220
|
+
* the public to_json/cc_json projections.
|
|
221
|
+
*/
|
|
222
|
+
function specificRecipientAddresses(addressing: NoteAddressing): string[] {
|
|
223
|
+
return [...new Set(allNoteAddresses(addressing))].filter(
|
|
224
|
+
(address) => !addressesPublic([address]) && !address.endsWith("/followers"),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hiddenRecipientAddresses(addressing: NoteAddressing): string[] {
|
|
229
|
+
return [...new Set([...addressing.bto, ...addressing.bcc])].filter(
|
|
230
|
+
(address) => !addressesPublic([address]) && !address.endsWith("/followers"),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Derive an object's reply counter from the indexed child edge set.
|
|
236
|
+
*
|
|
237
|
+
* Federation delivery is unordered: a child can commit while its parent is
|
|
238
|
+
* still unknown, making the child's immediate parent UPDATE a legitimate
|
|
239
|
+
* zero-row no-op. Every later parent insert or duplicate delivery must run this
|
|
240
|
+
* statement after the insert attempt so both arrival orders — and old stale
|
|
241
|
+
* rows — converge without depending on a child retry.
|
|
242
|
+
*/
|
|
243
|
+
function recomputeObjectReplyCount(db: Database, objectId: string) {
|
|
244
|
+
return db
|
|
245
|
+
.update(objects)
|
|
246
|
+
.set({
|
|
247
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${objectId})`,
|
|
248
|
+
})
|
|
249
|
+
.where(eq(objects.apId, objectId));
|
|
250
|
+
}
|
|
251
|
+
|
|
166
252
|
/**
|
|
167
253
|
* Recipient-INDEPENDENT visibility classification for an inbound generic Note,
|
|
168
254
|
* mirroring the local outbound addressing contract. CRITICAL invariant: a
|
|
169
255
|
* non-public Note is NEVER classified as "unlisted" (world-readable). Direct
|
|
170
|
-
*
|
|
171
|
-
*
|
|
256
|
+
* addressed Notes are normally diverted before the generic Create insert, but
|
|
257
|
+
* the classifier itself remains total so every caller fails closed:
|
|
172
258
|
* - "public" — the Public collection is in `to`;
|
|
173
|
-
* - "unlisted" — Public is only in `cc` (Mastodon-style unlisted)
|
|
174
|
-
* note carries no usable addressing at all;
|
|
259
|
+
* - "unlisted" — Public is only in `cc` (Mastodon-style unlisted);
|
|
175
260
|
* - "followers" — a followers collection is addressed and Public is absent.
|
|
261
|
+
* - "direct" — only actor IRIs, or no usable addressing at all. The empty
|
|
262
|
+
* case is intentionally unreadable rather than world-readable.
|
|
176
263
|
* Previously this was derived solely from `to.includes(Public)`, so a remote
|
|
177
264
|
* followers-only post (Public absent) was silently downgraded to "unlisted" and
|
|
178
265
|
* became world-readable. */
|
|
179
|
-
function classifyInboundNoteVisibility(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const to = object.to ?? [];
|
|
184
|
-
const cc = object.cc ?? [];
|
|
266
|
+
function classifyInboundNoteVisibility(
|
|
267
|
+
addressing: NoteAddressing,
|
|
268
|
+
): "public" | "unlisted" | "followers" | "direct" {
|
|
269
|
+
const { to, cc, bto, bcc } = addressing;
|
|
185
270
|
if (addressesPublic(to)) return "public";
|
|
186
271
|
if (addressesPublic(cc)) return "unlisted";
|
|
187
|
-
|
|
188
|
-
|
|
272
|
+
// Hidden fields are still audience authority. They are considered for reach
|
|
273
|
+
// classification but are never copied into visible addressing projections.
|
|
274
|
+
if (addressesPublic([...bto, ...bcc])) return "public";
|
|
275
|
+
if (addressesFollowers([...to, ...cc, ...bto, ...bcc])) return "followers";
|
|
276
|
+
return "direct";
|
|
189
277
|
}
|
|
190
278
|
|
|
191
279
|
/**
|
|
@@ -196,63 +284,42 @@ function classifyInboundNoteVisibility(object: {
|
|
|
196
284
|
* insertDirectNote. Recipient-independent (keyed on the activity's own
|
|
197
285
|
* addressing), unlike isDirectNote.
|
|
198
286
|
*/
|
|
199
|
-
function isDirectShapedNote(
|
|
200
|
-
const all =
|
|
287
|
+
function isDirectShapedNote(addressing: NoteAddressing): boolean {
|
|
288
|
+
const all = allNoteAddresses(addressing);
|
|
201
289
|
if (all.length === 0) return false;
|
|
202
290
|
if (addressesPublic(all)) return false;
|
|
203
291
|
if (addressesFollowers(all)) return false;
|
|
204
292
|
return true;
|
|
205
293
|
}
|
|
206
294
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// recipient (mention) gate working.
|
|
210
|
-
export const MAX_ADDRESS_ENTRIES = 64;
|
|
211
|
-
function boundAddressJson(addresses: string[] | undefined): string {
|
|
212
|
-
if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
|
|
213
|
-
return JSON.stringify(
|
|
214
|
-
addresses
|
|
215
|
-
.filter((a) => typeof a === "string")
|
|
216
|
-
.slice(0, MAX_ADDRESS_ENTRIES),
|
|
217
|
-
);
|
|
295
|
+
function boundAddressJson(value: unknown): string {
|
|
296
|
+
return JSON.stringify(addressList(value));
|
|
218
297
|
}
|
|
219
298
|
|
|
220
299
|
/**
|
|
221
|
-
* Normalize
|
|
222
|
-
*
|
|
223
|
-
* strings and embedded objects
|
|
224
|
-
*
|
|
225
|
-
* huge `IN (...)` lookup.
|
|
300
|
+
* Normalize one already-validated AS2 addressing field (`to` / `cc` /
|
|
301
|
+
* `audience`). The field may be absent, a bare string, or an array mixing
|
|
302
|
+
* strings and embedded objects. Validation happens once for the complete
|
|
303
|
+
* envelope/object projection before any caller derives reach from these lists.
|
|
226
304
|
*/
|
|
227
305
|
function addressList(value: unknown): string[] {
|
|
228
306
|
if (typeof value === "string") return [value];
|
|
229
307
|
if (!Array.isArray(value)) return [];
|
|
230
|
-
return value
|
|
231
|
-
.filter((a): a is string => typeof a === "string")
|
|
232
|
-
.slice(0, MAX_ADDRESS_ENTRIES);
|
|
308
|
+
return [...new Set(value.filter((a): a is string => typeof a === "string"))];
|
|
233
309
|
}
|
|
234
310
|
|
|
235
311
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* never under the local domain. Returns true when the object id must be
|
|
240
|
-
* rejected. Mirrors the ownership checks already enforced for Delete/Update.
|
|
312
|
+
* The embedded object's audience is the persisted object projection. Peers
|
|
313
|
+
* that put audience only on the activity envelope remain compatible, while an
|
|
314
|
+
* explicit object-level [] wins and can clear the scope on Update.
|
|
241
315
|
*/
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (isLocal(objectId, baseUrl)) return true;
|
|
250
|
-
try {
|
|
251
|
-
return getDomain(objectId) !== getDomain(actor);
|
|
252
|
-
} catch {
|
|
253
|
-
// Unparseable object id: treat as a mismatch (reject) rather than insert.
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
316
|
+
function normalizedObjectAudience(
|
|
317
|
+
activity: Activity,
|
|
318
|
+
object: ActivityObject,
|
|
319
|
+
): string[] {
|
|
320
|
+
const source =
|
|
321
|
+
object.audience !== undefined ? object.audience : activity.audience;
|
|
322
|
+
return [...new Set(addressList(source))];
|
|
256
323
|
}
|
|
257
324
|
|
|
258
325
|
/**
|
|
@@ -286,19 +353,18 @@ function extractMentionHrefs(tag: unknown): string[] {
|
|
|
286
353
|
}
|
|
287
354
|
|
|
288
355
|
/**
|
|
289
|
-
* Detect an inbound direct (DM) Note: it is addressed (in
|
|
290
|
-
* more recipients but NOT to the Public
|
|
291
|
-
* collection
|
|
292
|
-
* who is necessarily a known local actor row.
|
|
293
|
-
* in dm/messages.ts (visibility="direct",
|
|
356
|
+
* Detect an inbound direct (DM) Note: it is addressed (in
|
|
357
|
+
* `to`/`cc`/`bto`/`bcc`) to one or more recipients but NOT to the Public
|
|
358
|
+
* collection and NOT to a followers collection. The local addressed recipient
|
|
359
|
+
* is the inbox owner (`recipient`), who is necessarily a known local actor row.
|
|
360
|
+
* Mirrors the outbound DM contract in dm/messages.ts (visibility="direct",
|
|
361
|
+
* to=[recipient]).
|
|
294
362
|
*/
|
|
295
363
|
function isDirectNote(
|
|
296
|
-
|
|
364
|
+
addressing: NoteAddressing,
|
|
297
365
|
recipient: ActorRow,
|
|
298
366
|
): boolean {
|
|
299
|
-
const
|
|
300
|
-
const cc = object.cc ?? [];
|
|
301
|
-
const all = [...to, ...cc];
|
|
367
|
+
const all = allNoteAddresses(addressing);
|
|
302
368
|
if (all.length === 0) return false;
|
|
303
369
|
// Direct notes are never addressed to the Public collection...
|
|
304
370
|
if (addressesPublic(all)) return false;
|
|
@@ -307,9 +373,10 @@ function isDirectNote(
|
|
|
307
373
|
if (recipient.followersUrl && all.includes(recipient.followersUrl)) {
|
|
308
374
|
return false;
|
|
309
375
|
}
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
376
|
+
// Every AS2 audience field names recipients. `bto`/`bcc` are private, not
|
|
377
|
+
// non-authoritative; dropping them here makes a correctly routed hidden DM
|
|
378
|
+
// persist without recipient authority and therefore disappear from the UX.
|
|
379
|
+
return all.includes(recipient.apId);
|
|
313
380
|
}
|
|
314
381
|
|
|
315
382
|
/**
|
|
@@ -318,10 +385,10 @@ function isDirectNote(
|
|
|
318
385
|
* dm/messages.ts: a direct-visibility Note row, an objectRecipients row, a
|
|
319
386
|
* stored inbound Create activity, and an inbox row so it surfaces.
|
|
320
387
|
*
|
|
321
|
-
*
|
|
322
|
-
* strictly 1:1 (to=[otherApId])
|
|
323
|
-
*
|
|
324
|
-
*
|
|
388
|
+
* Each invocation records delivery for one local inbox owner. The outbound DM
|
|
389
|
+
* UX remains strictly 1:1 (to=[otherApId]), so only single-recipient direct
|
|
390
|
+
* Notes receive an `objects.conversation` id; multi-recipient delivery keeps
|
|
391
|
+
* recipient authority without pretending that it belongs to a 1:1 thread.
|
|
325
392
|
*/
|
|
326
393
|
async function insertDirectNote(
|
|
327
394
|
db: Database,
|
|
@@ -331,41 +398,35 @@ async function insertDirectNote(
|
|
|
331
398
|
actor: string,
|
|
332
399
|
recipient: ActorRow,
|
|
333
400
|
baseUrl: string,
|
|
401
|
+
addressing: NoteAddressing,
|
|
402
|
+
parentId: string | null,
|
|
334
403
|
): Promise<void> {
|
|
335
404
|
// Derive the conversation. Honour a sender-supplied `object.conversation`
|
|
336
405
|
// only when it matches the value yurucommu itself would compute for this
|
|
337
406
|
// (sender, localRecipient) pair — otherwise a remote actor could force a
|
|
338
407
|
// message into an arbitrary thread (spoof a reply context). Fall back to the
|
|
339
408
|
// computed id for foreign-origin DMs that carry no/invalid conversation.
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
409
|
+
const directRecipients = specificRecipientAddresses(addressing);
|
|
410
|
+
const computedConversation =
|
|
411
|
+
directRecipients.length === 1
|
|
412
|
+
? getConversationId(baseUrl, actor, directRecipients[0])
|
|
413
|
+
: null;
|
|
345
414
|
const conversationId =
|
|
346
|
-
object.conversation === computedConversation
|
|
415
|
+
computedConversation && object.conversation === computedConversation
|
|
347
416
|
? object.conversation
|
|
348
417
|
: computedConversation;
|
|
349
418
|
|
|
350
|
-
const attachments = object.attachment
|
|
351
|
-
? JSON.stringify(object.attachment)
|
|
352
|
-
: "[]";
|
|
353
419
|
const publishedAt = normalizeInboundTimestamp(
|
|
354
420
|
object.published,
|
|
355
421
|
new Date().toISOString(),
|
|
356
422
|
);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const existingObject = await db
|
|
365
|
-
.select({ apId: objects.apId })
|
|
366
|
-
.from(objects)
|
|
367
|
-
.where(eq(objects.apId, objectId))
|
|
368
|
-
.get();
|
|
423
|
+
|
|
424
|
+
const replyCountStatements = [
|
|
425
|
+
...(parentId ? [recomputeObjectReplyCount(db, parentId)] : []),
|
|
426
|
+
// A direct Note can itself be a late-arriving parent. Recompute even when
|
|
427
|
+
// the insert conflicts so a peer retry repairs a stale legacy counter.
|
|
428
|
+
recomputeObjectReplyCount(db, objectId),
|
|
429
|
+
];
|
|
369
430
|
|
|
370
431
|
// #3 (atomicity + idempotency): the object insert and the author postCount
|
|
371
432
|
// bump MUST commit together. Previously the row was inserted
|
|
@@ -392,10 +453,15 @@ async function insertDirectNote(
|
|
|
392
453
|
attributedTo: actor,
|
|
393
454
|
content: boundInboundContent(object.content),
|
|
394
455
|
summary: boundInboundSummary(object.summary),
|
|
395
|
-
attachmentsJson:
|
|
396
|
-
|
|
456
|
+
attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
|
|
457
|
+
tagsJson: boundInboundTagsJson(object.tag),
|
|
458
|
+
inReplyTo: parentId,
|
|
397
459
|
visibility: "direct",
|
|
398
|
-
|
|
460
|
+
// Only visible audience fields are serialized. Hidden `bto` / `bcc`
|
|
461
|
+
// recipients live exclusively in object_recipients and must never be
|
|
462
|
+
// disclosed through object JSON or the post API.
|
|
463
|
+
toJson: boundAddressJson(addressing.to),
|
|
464
|
+
ccJson: boundAddressJson(addressing.cc),
|
|
399
465
|
conversation: conversationId,
|
|
400
466
|
communityApId: null,
|
|
401
467
|
published: publishedAt,
|
|
@@ -420,12 +486,13 @@ async function insertDirectNote(
|
|
|
420
486
|
type: "to",
|
|
421
487
|
})
|
|
422
488
|
.onConflictDoNothing(),
|
|
489
|
+
...replyCountStatements,
|
|
423
490
|
]);
|
|
424
491
|
|
|
425
|
-
if (existingObject) return; // duplicate: no inbox surfacing, no double count
|
|
426
|
-
|
|
427
492
|
// Store the inbound Create and surface it in the recipient's inbox so the DM
|
|
428
|
-
// appears in the conversation / message-requests view.
|
|
493
|
+
// appears in the conversation / message-requests view. Both inserts are
|
|
494
|
+
// idempotent, so every local recipient in a shared-inbox fan-out gets its own
|
|
495
|
+
// inbox row and a retry repairs a missing side effect safely.
|
|
429
496
|
const activityId = activity.id || activityApId(baseUrl, generateId());
|
|
430
497
|
await upsertActivityAndNotify(
|
|
431
498
|
db,
|
|
@@ -462,53 +529,94 @@ export async function handleCreate(
|
|
|
462
529
|
// Handle Note type (a remote may send `type` as a string or an array)
|
|
463
530
|
if (!typeIncludes(object.type, "Note")) return;
|
|
464
531
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (isObjectIdOriginMismatch(object.id, actor, baseUrl)) {
|
|
470
|
-
log.warn("Create rejected: object id origin does not match actor", {
|
|
471
|
-
event: "ap.create.object_origin_mismatch",
|
|
532
|
+
const identity = validateInboundObjectIdentity(object.id, actor, baseUrl);
|
|
533
|
+
if (!identity.ok) {
|
|
534
|
+
log.warn("Create rejected: invalid remote object identity", {
|
|
535
|
+
event: "ap.create.object_identity_invalid",
|
|
472
536
|
actor,
|
|
473
537
|
objectId: object.id,
|
|
538
|
+
reason: identity.reason,
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const addressingContract = collectBoundedInboundAddresses([activity, object]);
|
|
544
|
+
if (!addressingContract.ok) {
|
|
545
|
+
log.warn("Create(Note) rejected: invalid addressing projection", {
|
|
546
|
+
event: "ap.create.note_addressing_invalid",
|
|
547
|
+
actor,
|
|
548
|
+
objectId: identity.objectId,
|
|
549
|
+
reason: addressingContract.reason,
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const replyTarget = validateInboundReplyTarget(object.inReplyTo);
|
|
555
|
+
if (!replyTarget.ok) {
|
|
556
|
+
log.warn("Create(Note) rejected: invalid reply target", {
|
|
557
|
+
event: "ap.create.note_reply_target_invalid",
|
|
558
|
+
actor,
|
|
559
|
+
objectId: identity.objectId,
|
|
560
|
+
reason: replyTarget.reason,
|
|
474
561
|
});
|
|
475
562
|
return;
|
|
476
563
|
}
|
|
564
|
+
const parentId = replyTarget.parentId ?? null;
|
|
477
565
|
|
|
478
566
|
// Direct (DM) Note routing: a Note addressed to the local inbox owner that
|
|
479
567
|
// is neither public nor follower-only belongs in the recipient's DM inbox /
|
|
480
568
|
// message-request flow rather than the generic public Note insert.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
569
|
+
const addressing = createNoteAddressing(activity, object);
|
|
570
|
+
|
|
571
|
+
const parentObj = parentId
|
|
572
|
+
? await db
|
|
573
|
+
.select({
|
|
574
|
+
apId: objects.apId,
|
|
575
|
+
attributedTo: objects.attributedTo,
|
|
576
|
+
visibility: objects.visibility,
|
|
577
|
+
toJson: objects.toJson,
|
|
578
|
+
ccJson: objects.ccJson,
|
|
579
|
+
audienceJson: objects.audienceJson,
|
|
580
|
+
communityApId: objects.communityApId,
|
|
581
|
+
type: objects.type,
|
|
582
|
+
endTime: objects.endTime,
|
|
583
|
+
})
|
|
584
|
+
.from(objects)
|
|
585
|
+
.where(eq(objects.apId, parentId))
|
|
586
|
+
.get()
|
|
587
|
+
: null;
|
|
588
|
+
|
|
589
|
+
// Inbound replies must pass the canonical read gate for EVERY parent retained
|
|
590
|
+
// by this instance, including direct Notes and remote-authored objects
|
|
591
|
+
// delivered to a local recipient. Keeping this check before the direct/generic
|
|
592
|
+
// routing split prevents either storage path from becoming a restricted-thread
|
|
593
|
+
// injection bypass. Personal block/mute state remains local-owner state, so
|
|
594
|
+
// that extra guard applies only when the parent author is local.
|
|
595
|
+
if (parentId && parentObj) {
|
|
596
|
+
if (!(await canViewerReadObjectFull(db, parentObj, actor))) return;
|
|
597
|
+
if (
|
|
598
|
+
isLocal(parentObj.attributedTo, baseUrl) &&
|
|
599
|
+
(await actorSuppressesInteractionFrom(db, parentObj.attributedTo, actor))
|
|
600
|
+
) {
|
|
504
601
|
return;
|
|
505
602
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Root/public/followers/community Notes are still writes into this local
|
|
606
|
+
// recipient's feed. Apply the same block/mute policy as DM, reply, mention,
|
|
607
|
+
// Follow, Like, Announce, and Story before any durable projection is created.
|
|
608
|
+
// The shared inbox invokes this handler once per resolved local recipient, so
|
|
609
|
+
// the decision remains recipient-specific on that path.
|
|
610
|
+
if (await actorSuppressesInteractionFrom(db, recipient.apId, actor)) {
|
|
611
|
+
log.info("Dropped inbound Note from a blocked or muted actor", {
|
|
612
|
+
event: "ap.create.note_suppressed",
|
|
613
|
+
actor,
|
|
614
|
+
recipient: recipient.apId,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (object.id && isDirectNote(addressing, recipient)) {
|
|
512
620
|
await insertDirectNote(
|
|
513
621
|
db,
|
|
514
622
|
activity,
|
|
@@ -517,6 +625,8 @@ export async function handleCreate(
|
|
|
517
625
|
actor,
|
|
518
626
|
recipient,
|
|
519
627
|
baseUrl,
|
|
628
|
+
addressing,
|
|
629
|
+
parentId,
|
|
520
630
|
);
|
|
521
631
|
return;
|
|
522
632
|
}
|
|
@@ -527,7 +637,7 @@ export async function handleCreate(
|
|
|
527
637
|
// addressed to actor A is also dispatched for an unrelated follower B. We must
|
|
528
638
|
// NOT store it as a world-readable generic Note for B — the addressed actor's
|
|
529
639
|
// own delivery handles it via insertDirectNote above. Skip it here.
|
|
530
|
-
if (isDirectShapedNote(
|
|
640
|
+
if (isDirectShapedNote(addressing)) {
|
|
531
641
|
log.warn("Skipping direct Note not addressed to this recipient", {
|
|
532
642
|
event: "ap.create.direct_note_not_addressed",
|
|
533
643
|
actor,
|
|
@@ -537,7 +647,7 @@ export async function handleCreate(
|
|
|
537
647
|
return;
|
|
538
648
|
}
|
|
539
649
|
|
|
540
|
-
const objectId =
|
|
650
|
+
const objectId = identity.objectId;
|
|
541
651
|
|
|
542
652
|
// Was the object already present BEFORE this dispatch? This is read ONCE and
|
|
543
653
|
// used only to gate the one-shot side effects (parent notification) below; it
|
|
@@ -551,49 +661,18 @@ export async function handleCreate(
|
|
|
551
661
|
.where(eq(objects.apId, objectId))
|
|
552
662
|
.get();
|
|
553
663
|
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
664
|
+
const audience = normalizedObjectAudience(activity, object);
|
|
665
|
+
const communityScope = await resolveInboundCommunityScope(
|
|
666
|
+
db,
|
|
667
|
+
actor,
|
|
668
|
+
audience,
|
|
669
|
+
);
|
|
670
|
+
if (!communityScope.allowed) return;
|
|
671
|
+
|
|
557
672
|
const publishedAt = normalizeInboundTimestamp(
|
|
558
673
|
object.published,
|
|
559
674
|
new Date().toISOString(),
|
|
560
675
|
);
|
|
561
|
-
const parentObj = object.inReplyTo
|
|
562
|
-
? await db
|
|
563
|
-
.select({
|
|
564
|
-
attributedTo: objects.attributedTo,
|
|
565
|
-
visibility: objects.visibility,
|
|
566
|
-
toJson: objects.toJson,
|
|
567
|
-
ccJson: objects.ccJson,
|
|
568
|
-
audienceJson: objects.audienceJson,
|
|
569
|
-
communityApId: objects.communityApId,
|
|
570
|
-
type: objects.type,
|
|
571
|
-
endTime: objects.endTime,
|
|
572
|
-
})
|
|
573
|
-
.from(objects)
|
|
574
|
-
.where(eq(objects.apId, object.inReplyTo))
|
|
575
|
-
.get()
|
|
576
|
-
: null;
|
|
577
|
-
|
|
578
|
-
// Inbound reply to a LOCAL parent the sending actor cannot read (or that has
|
|
579
|
-
// blocked them) is REFUSED — mirroring the local reply 404 gate
|
|
580
|
-
// (routes/posts/routes.ts). Without this, a remote who merely learns a
|
|
581
|
-
// followers-only / direct(DM) / private-community post's apId could inflate its
|
|
582
|
-
// replyCount, deliver a reply notification to the owner (bypassing a personal
|
|
583
|
-
// block), and — because the stored reply's in_reply_to discloses the parent —
|
|
584
|
-
// build an existence oracle for the restricted post. A legitimate follower /
|
|
585
|
-
// addressed recipient still passes canViewerReadObjectFull, so their reply is
|
|
586
|
-
// ingested normally. (Only gated for LOCAL parents: a remote parent's audience
|
|
587
|
-
// is the remote instance's concern, and we hold no counter/notification for it.)
|
|
588
|
-
if (
|
|
589
|
-
object.inReplyTo &&
|
|
590
|
-
parentObj &&
|
|
591
|
-
isLocal(parentObj.attributedTo, baseUrl) &&
|
|
592
|
-
(!(await canViewerReadObjectFull(db, parentObj, actor)) ||
|
|
593
|
-
(await actorIsBlockedBy(db, parentObj.attributedTo, actor)))
|
|
594
|
-
) {
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
676
|
|
|
598
677
|
const shouldNotifyParent = !!(
|
|
599
678
|
parentObj && isLocal(parentObj.attributedTo, baseUrl)
|
|
@@ -626,17 +705,19 @@ export async function handleCreate(
|
|
|
626
705
|
attributedTo: actor,
|
|
627
706
|
content: boundInboundContent(object.content),
|
|
628
707
|
summary: boundInboundSummary(object.summary),
|
|
629
|
-
attachmentsJson:
|
|
630
|
-
|
|
708
|
+
attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
|
|
709
|
+
tagsJson: boundInboundTagsJson(object.tag),
|
|
710
|
+
inReplyTo: parentId,
|
|
631
711
|
// Recipient-independent classification: a non-public Note is never stored
|
|
632
712
|
// as world-readable "unlisted". A followers-only post → "followers" (gated
|
|
633
713
|
// by the accepted-follow edge), preserving the remote author's audience.
|
|
634
|
-
visibility: classifyInboundNoteVisibility(
|
|
714
|
+
visibility: classifyInboundNoteVisibility(addressing),
|
|
635
715
|
// Persist the addressing so the explicit-recipient (mention) gate in
|
|
636
716
|
// canViewerReadObjectFull / the post-detail route can evaluate.
|
|
637
|
-
toJson: boundAddressJson(
|
|
638
|
-
ccJson: boundAddressJson(
|
|
639
|
-
|
|
717
|
+
toJson: boundAddressJson(addressing.to),
|
|
718
|
+
ccJson: boundAddressJson(addressing.cc),
|
|
719
|
+
audienceJson: JSON.stringify(audience),
|
|
720
|
+
communityApId: communityScope.communityApId,
|
|
640
721
|
published: publishedAt,
|
|
641
722
|
isLocal: 0,
|
|
642
723
|
})
|
|
@@ -647,20 +728,37 @@ export async function handleCreate(
|
|
|
647
728
|
.set({ postCount: sql`${actors.postCount} + 1` })
|
|
648
729
|
.where(and(eq(actors.apId, actor), objectAbsent));
|
|
649
730
|
|
|
650
|
-
|
|
651
|
-
|
|
731
|
+
// Persist hidden bto/bcc recipients as indexed authority projections on the
|
|
732
|
+
// generic path. Visible to/cc recipients already remain authoritative in the
|
|
733
|
+
// JSON projection; duplicating them here is unnecessary and would turn an
|
|
734
|
+
// ordinary remote @mention into a recipient-table dependency. The canonical
|
|
735
|
+
// read gate combines both representations without revealing hidden reach.
|
|
736
|
+
const recipientProjectionStatements = insertMany(
|
|
737
|
+
db,
|
|
738
|
+
objectRecipients,
|
|
739
|
+
hiddenRecipientAddresses(addressing).map((recipientApId) => ({
|
|
740
|
+
objectApId: objectId,
|
|
741
|
+
recipientApId,
|
|
742
|
+
type: "to",
|
|
743
|
+
})),
|
|
744
|
+
{ conflict: "ignore" },
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
if (parentId) {
|
|
652
748
|
await runBatch(db, [
|
|
653
749
|
bumpPostCount,
|
|
654
750
|
insertObject,
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
659
|
-
})
|
|
660
|
-
.where(eq(objects.apId, parentId)),
|
|
751
|
+
...recipientProjectionStatements,
|
|
752
|
+
recomputeObjectReplyCount(db, parentId),
|
|
753
|
+
recomputeObjectReplyCount(db, objectId),
|
|
661
754
|
]);
|
|
662
755
|
} else {
|
|
663
|
-
await runBatch(db, [
|
|
756
|
+
await runBatch(db, [
|
|
757
|
+
bumpPostCount,
|
|
758
|
+
insertObject,
|
|
759
|
+
...recipientProjectionStatements,
|
|
760
|
+
recomputeObjectReplyCount(db, objectId),
|
|
761
|
+
]);
|
|
664
762
|
}
|
|
665
763
|
|
|
666
764
|
if (existingBeforeInsert) return; // duplicate: no double notification
|
|
@@ -683,7 +781,8 @@ export async function handleCreate(
|
|
|
683
781
|
// that only ever fired for local-origin posts). Runs once (the duplicate
|
|
684
782
|
// delivery short-circuits at `existingBeforeInsert` above). Skips the post
|
|
685
783
|
// author and the parent author (already notified by the reply branch) and
|
|
686
|
-
// honors the mentioned actor's block of the sender, mirroring the reply
|
|
784
|
+
// honors the mentioned actor's block/mute of the sender, mirroring the reply
|
|
785
|
+
// gate.
|
|
687
786
|
const mentionedLocalApIds = new Set<string>();
|
|
688
787
|
for (const href of extractMentionHrefs(object.tag)) {
|
|
689
788
|
if (!isLocal(href, baseUrl)) continue;
|
|
@@ -714,7 +813,8 @@ export async function handleCreate(
|
|
|
714
813
|
).flat();
|
|
715
814
|
|
|
716
815
|
for (const { apId: mentionedApId } of existingLocalApIds) {
|
|
717
|
-
if (await
|
|
816
|
+
if (await actorSuppressesInteractionFrom(db, mentionedApId, actor))
|
|
817
|
+
continue;
|
|
718
818
|
await upsertActivityAndNotify(
|
|
719
819
|
db,
|
|
720
820
|
activityApId(baseUrl, generateId()),
|
|
@@ -741,18 +841,18 @@ export async function handleCreateStory(
|
|
|
741
841
|
const object = getActivityObject(activity);
|
|
742
842
|
if (!object) return;
|
|
743
843
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
event: "ap.story.object_origin_mismatch",
|
|
844
|
+
const identity = validateInboundObjectIdentity(object.id, actor, baseUrl);
|
|
845
|
+
if (!identity.ok) {
|
|
846
|
+
log.warn("Create(Story) rejected: invalid remote object identity", {
|
|
847
|
+
event: "ap.story.object_identity_invalid",
|
|
749
848
|
actor,
|
|
750
849
|
objectId: object.id,
|
|
850
|
+
reason: identity.reason,
|
|
751
851
|
});
|
|
752
852
|
return;
|
|
753
853
|
}
|
|
754
854
|
|
|
755
|
-
const objectId =
|
|
855
|
+
const objectId = identity.objectId;
|
|
756
856
|
|
|
757
857
|
// Per-user block: drop a Story from an actor the local owner has blocked,
|
|
758
858
|
// mirroring the inbound DM blockedBySigner drop + the inbound Like/Announce/
|
|
@@ -761,12 +861,7 @@ export async function handleCreateStory(
|
|
|
761
861
|
// stories were still stored (consuming the per-author cap + retrievable via
|
|
762
862
|
// GET /api/posts/:id). Single-user instance: any blocks row blocking this actor
|
|
763
863
|
// is the owner's block.
|
|
764
|
-
|
|
765
|
-
.select({ b: blocks.blockerApId })
|
|
766
|
-
.from(blocks)
|
|
767
|
-
.where(eq(blocks.blockedApId, actor))
|
|
768
|
-
.get();
|
|
769
|
-
if (blockedByOwner) return;
|
|
864
|
+
if (await ownerSuppressesInboundActor(db, actor)) return;
|
|
770
865
|
|
|
771
866
|
// Check if story already exists
|
|
772
867
|
const existing = await db
|
|
@@ -774,7 +869,12 @@ export async function handleCreateStory(
|
|
|
774
869
|
.from(objects)
|
|
775
870
|
.where(eq(objects.apId, objectId))
|
|
776
871
|
.get();
|
|
777
|
-
if (existing)
|
|
872
|
+
if (existing) {
|
|
873
|
+
// Duplicate Story delivery is a bounded repair opportunity for a parent
|
|
874
|
+
// inserted by an older version before one or more child replies arrived.
|
|
875
|
+
await recomputeObjectReplyCount(db, objectId);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
778
878
|
|
|
779
879
|
// Per-author flood cap. A hostile remote could Create() an unbounded number of
|
|
780
880
|
// Stories to bloat our feed/storage (each carries an attachment blob + caption).
|
|
@@ -803,119 +903,42 @@ export async function handleCreateStory(
|
|
|
803
903
|
return;
|
|
804
904
|
}
|
|
805
905
|
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
906
|
+
// Story metadata has a product-specific storage shape; never feed it through
|
|
907
|
+
// the generic Note attachment projection. Normalize every remote field at
|
|
908
|
+
// this boundary so malformed URLs/overlays cannot become durable UI data.
|
|
909
|
+
const storyProjection = buildInboundStoryCreateProjection(object);
|
|
910
|
+
if (!storyProjection) {
|
|
911
|
+
log.warn("Create(Story) rejected: invalid story projection", {
|
|
912
|
+
event: "ap.story.invalid_projection",
|
|
913
|
+
actor,
|
|
810
914
|
objectId,
|
|
811
915
|
});
|
|
812
916
|
return;
|
|
813
917
|
}
|
|
814
918
|
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
919
|
+
// Clamp+normalize `published` first and anchor the expiry to that durable
|
|
920
|
+
// value. Already-expired objects are discarded, closing the live-cap bypass
|
|
921
|
+
// where a sender could churn unlimited rows with a past endTime.
|
|
922
|
+
const now = new Date().toISOString();
|
|
923
|
+
const publishedAt = normalizeInboundTimestamp(object.published, now);
|
|
924
|
+
const endTime = normalizeInboundStoryCreateEndTime(
|
|
925
|
+
publishedAt,
|
|
926
|
+
object.endTime,
|
|
927
|
+
now,
|
|
928
|
+
);
|
|
929
|
+
if (!endTime) {
|
|
930
|
+
log.debug("Create(Story) dropped: already expired", {
|
|
931
|
+
event: "ap.story.expired_at_ingress",
|
|
932
|
+
actor,
|
|
829
933
|
objectId,
|
|
830
934
|
});
|
|
831
935
|
return;
|
|
832
936
|
}
|
|
833
937
|
|
|
834
|
-
// overlays validation (optional, validate if present). Cap the COUNT — the
|
|
835
|
-
// local create path bounds overlays via validateOverlays (MAX_OVERLAYS=20),
|
|
836
|
-
// and a hostile remote must not pad an unbounded array into attachments_json.
|
|
837
|
-
const MAX_INBOUND_OVERLAYS = 20;
|
|
838
|
-
let overlays: StoryOverlay[] | undefined;
|
|
839
|
-
if (Array.isArray(object.overlays)) {
|
|
840
|
-
const filtered = (object.overlays as StoryOverlay[])
|
|
841
|
-
.filter(
|
|
842
|
-
(o: StoryOverlay) =>
|
|
843
|
-
o &&
|
|
844
|
-
o.position &&
|
|
845
|
-
typeof o.position.x === "number" &&
|
|
846
|
-
typeof o.position.y === "number",
|
|
847
|
-
)
|
|
848
|
-
.slice(0, MAX_INBOUND_OVERLAYS);
|
|
849
|
-
// Keep at most ONE Question (poll) overlay — votes are keyed only by
|
|
850
|
-
// (storyApId, actorApId) and tallied by optionIndex with no question
|
|
851
|
-
// dimension, so a second poll would conflate tallies. Mirror validateOverlays.
|
|
852
|
-
let seenQuestion = false;
|
|
853
|
-
const capped = filtered.filter((o: StoryOverlay) => {
|
|
854
|
-
if (o.type === "Question") {
|
|
855
|
-
if (seenQuestion) return false;
|
|
856
|
-
seenQuestion = true;
|
|
857
|
-
}
|
|
858
|
-
return true;
|
|
859
|
-
});
|
|
860
|
-
if (capped.length > 0) overlays = capped;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// Build attachments_json
|
|
864
|
-
const attachmentData = {
|
|
865
|
-
attachment: {
|
|
866
|
-
r2_key: "", // Remote stories don't have local R2 key
|
|
867
|
-
content_type: attachment.mediaType || "image/jpeg",
|
|
868
|
-
url: attachment.url,
|
|
869
|
-
width: attachment.width || 1080,
|
|
870
|
-
height: attachment.height || 1920,
|
|
871
|
-
},
|
|
872
|
-
displayDuration:
|
|
873
|
-
(object as { displayDuration?: string }).displayDuration || "PT5S",
|
|
874
|
-
// The remote caption arrives as the AS2 Note `content`; persist it (bounded
|
|
875
|
-
// to the same local content cap as every other inbound Note path) so the
|
|
876
|
-
// local renderer shows the same caption as the originating instance.
|
|
877
|
-
caption:
|
|
878
|
-
typeof object.content === "string" && object.content.trim().length > 0
|
|
879
|
-
? boundInboundContent(object.content)
|
|
880
|
-
: undefined,
|
|
881
|
-
overlays,
|
|
882
|
-
};
|
|
883
|
-
|
|
884
|
-
const now = new Date().toISOString();
|
|
885
|
-
// Clamp the attacker-controlled `endTime`: a story must expire. A non-ISO or
|
|
886
|
-
// far-future value stored verbatim would never satisfy the expiry filter
|
|
887
|
-
// (`lt(endTime, now)`, a lexical compare), so a malicious remote could create
|
|
888
|
-
// never-expiring stories that accumulate forever. Bound it to published + ~25h
|
|
889
|
-
// (the ~24h story lifetime + slack) and normalize to ISO so the compare holds.
|
|
890
|
-
const STORY_MAX_LIFETIME_MS = 25 * 60 * 60 * 1000;
|
|
891
|
-
// Clamp+normalize the inbound `published` FIRST and anchor the endTime bound to
|
|
892
|
-
// THAT, not the raw value: a far-future `published` ("9999-…") would otherwise
|
|
893
|
-
// push maxEndMs far into the future too and defeat this very expiry clamp.
|
|
894
|
-
const publishedAt = normalizeInboundTimestamp(object.published, now);
|
|
895
|
-
const publishedMs = Date.parse(publishedAt);
|
|
896
|
-
const maxEndMs =
|
|
897
|
-
(Number.isNaN(publishedMs) ? Date.now() : publishedMs) +
|
|
898
|
-
STORY_MAX_LIFETIME_MS;
|
|
899
|
-
const requestedEndMs = object.endTime ? Date.parse(object.endTime) : NaN;
|
|
900
|
-
const endTime = new Date(
|
|
901
|
-
Number.isNaN(requestedEndMs)
|
|
902
|
-
? maxEndMs
|
|
903
|
-
: Math.min(requestedEndMs, maxEndMs),
|
|
904
|
-
).toISOString();
|
|
905
|
-
|
|
906
938
|
// The early existence check above is best-effort (TOCTOU): two isolates
|
|
907
939
|
// racing the same cold story can both pass it. `onConflictDoNothing` keeps
|
|
908
940
|
// that race insert-safe, and gating follow-on side effects on the returned
|
|
909
941
|
// row mirrors the duplicate guard in handleCreate.
|
|
910
|
-
// Bound the serialized story data. Caption is capped and overlays are
|
|
911
|
-
// count-limited above, but per-overlay padding could still inflate it; if the
|
|
912
|
-
// blob exceeds the attachments cap, drop the (decorative) overlays so the core
|
|
913
|
-
// attachment + caption still persist within bounds.
|
|
914
|
-
let storyDataJson = JSON.stringify(attachmentData);
|
|
915
|
-
if (storyDataJson.length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
916
|
-
storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
|
|
917
|
-
}
|
|
918
|
-
|
|
919
942
|
// Carry the community scope across the federation boundary. A story that
|
|
920
943
|
// arrived through community fanout is addressed to the community's followers
|
|
921
944
|
// collection, not the author's; storing it with no scope made the local read
|
|
@@ -924,26 +947,24 @@ export async function handleCreateStory(
|
|
|
924
947
|
// communities this instance actually knows and only then mark the scope —
|
|
925
948
|
// an unresolvable audience is left unscoped rather than trusted, and the
|
|
926
949
|
// membership gate is still evaluated locally against `community_members`.
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
.get()
|
|
946
|
-
: undefined;
|
|
950
|
+
const storyAddressing = storyAddressedCollections(activity, object);
|
|
951
|
+
if (storyAddressing.overflow) {
|
|
952
|
+
log.warn("Create(Story) rejected: too many addressing entries", {
|
|
953
|
+
event: "ap.story.addressing_overflow",
|
|
954
|
+
actor,
|
|
955
|
+
objectId,
|
|
956
|
+
});
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const communityScope = await resolveInboundCommunityScope(
|
|
960
|
+
db,
|
|
961
|
+
actor,
|
|
962
|
+
storyAddressing.addresses,
|
|
963
|
+
);
|
|
964
|
+
if (!communityScope.allowed) return;
|
|
965
|
+
const storyAudience = communityScope.communityApId
|
|
966
|
+
? [communityScope.communityApId]
|
|
967
|
+
: normalizedObjectAudience(activity, object);
|
|
947
968
|
|
|
948
969
|
const inserted = await db
|
|
949
970
|
.insert(objects)
|
|
@@ -952,14 +973,19 @@ export async function handleCreateStory(
|
|
|
952
973
|
type: "Story",
|
|
953
974
|
attributedTo: actor,
|
|
954
975
|
content: "",
|
|
955
|
-
attachmentsJson:
|
|
956
|
-
...(
|
|
976
|
+
attachmentsJson: storyProjection.json,
|
|
977
|
+
...(communityScope.communityApId
|
|
957
978
|
? {
|
|
958
|
-
communityApId:
|
|
959
|
-
audienceJson: JSON.stringify(
|
|
979
|
+
communityApId: communityScope.communityApId,
|
|
980
|
+
audienceJson: JSON.stringify(storyAudience),
|
|
960
981
|
}
|
|
961
|
-
:
|
|
982
|
+
: storyAudience.length > 0
|
|
983
|
+
? { audienceJson: JSON.stringify(storyAudience) }
|
|
984
|
+
: {}),
|
|
962
985
|
endTime,
|
|
986
|
+
// Handles the child-before-parent order without a second transaction.
|
|
987
|
+
// The child Create path repairs the opposite concurrent order.
|
|
988
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${objectId})`,
|
|
963
989
|
published: publishedAt,
|
|
964
990
|
isLocal: 0,
|
|
965
991
|
})
|
|
@@ -999,9 +1025,10 @@ const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
|
|
|
999
1025
|
* a boost must never widen a followers-only/direct object's audience, and
|
|
1000
1026
|
* this instance cannot verify a remote author's follower audience.
|
|
1001
1027
|
*
|
|
1002
|
-
* Depth cap: the object's `inReplyTo` is stored verbatim
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1028
|
+
* Depth cap: the object's `inReplyTo` is stored verbatim and a retained parent
|
|
1029
|
+
* is authority-checked locally, but an unknown parent is NEVER fetched. A
|
|
1030
|
+
* single Announce therefore triggers at most one object fetch (plus a
|
|
1031
|
+
* best-effort author-profile cache fill), not a thread walk.
|
|
1005
1032
|
*
|
|
1006
1033
|
* Best-effort by contract: every failure returns false and the Announce is
|
|
1007
1034
|
* dropped exactly as it was before this path existed.
|
|
@@ -1013,7 +1040,13 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1013
1040
|
): Promise<boolean> {
|
|
1014
1041
|
// Never fetch a local id (a local object that does not exist is just gone)
|
|
1015
1042
|
// and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
|
|
1016
|
-
if (
|
|
1043
|
+
if (
|
|
1044
|
+
objectId.length > MAX_INBOUND_OBJECT_ID_LENGTH ||
|
|
1045
|
+
isLocal(objectId, baseUrl) ||
|
|
1046
|
+
!isSafeRemoteUrl(objectId)
|
|
1047
|
+
) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1017
1050
|
|
|
1018
1051
|
let note: ActivityObject & { attributedTo?: unknown };
|
|
1019
1052
|
try {
|
|
@@ -1047,18 +1080,72 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1047
1080
|
const attributedTo =
|
|
1048
1081
|
typeof note.attributedTo === "string" ? note.attributedTo : null;
|
|
1049
1082
|
if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
|
|
1050
|
-
|
|
1051
|
-
if (getDomain(attributedTo) !== getDomain(objectId)) return false;
|
|
1052
|
-
} catch {
|
|
1083
|
+
if (!validateInboundObjectIdentity(objectId, attributedTo, baseUrl).ok) {
|
|
1053
1084
|
return false;
|
|
1054
1085
|
}
|
|
1055
1086
|
|
|
1087
|
+
const addressingContract = collectBoundedInboundAddresses([note]);
|
|
1088
|
+
if (!addressingContract.ok) return false;
|
|
1089
|
+
|
|
1090
|
+
const replyTarget = validateInboundReplyTarget(note.inReplyTo);
|
|
1091
|
+
if (!replyTarget.ok) return false;
|
|
1092
|
+
const parentId = replyTarget.parentId ?? null;
|
|
1093
|
+
|
|
1056
1094
|
// Addressing gates: a DM-shaped object must never be stored world-readable,
|
|
1057
1095
|
// and a non-public classification is refused outright (see doc comment).
|
|
1058
|
-
|
|
1059
|
-
|
|
1096
|
+
const addressing = noteAddressing(note);
|
|
1097
|
+
if (isDirectShapedNote(addressing)) return false;
|
|
1098
|
+
const visibility = classifyInboundNoteVisibility(addressing);
|
|
1060
1099
|
if (visibility !== "public" && visibility !== "unlisted") return false;
|
|
1061
1100
|
|
|
1101
|
+
const audience = addressList(note.audience);
|
|
1102
|
+
const communityScope = await resolveInboundCommunityScope(
|
|
1103
|
+
db,
|
|
1104
|
+
attributedTo,
|
|
1105
|
+
audience,
|
|
1106
|
+
);
|
|
1107
|
+
if (!communityScope.allowed) return false;
|
|
1108
|
+
|
|
1109
|
+
// A fetched boost target can itself be a reply. If its parent is already
|
|
1110
|
+
// retained, apply the exact same read/suppression authority as ordinary
|
|
1111
|
+
// inbound Create before storing the child. Without this check an Announce of
|
|
1112
|
+
// an otherwise-public Note could inject a reply beneath a local direct or
|
|
1113
|
+
// followers-only parent that its author cannot read. An unknown parent stays
|
|
1114
|
+
// unresolved (the one-fetch depth cap below); later parent arrival repairs
|
|
1115
|
+
// its derived counter through recomputeObjectReplyCount.
|
|
1116
|
+
const parentObj = parentId
|
|
1117
|
+
? await db
|
|
1118
|
+
.select({
|
|
1119
|
+
apId: objects.apId,
|
|
1120
|
+
attributedTo: objects.attributedTo,
|
|
1121
|
+
visibility: objects.visibility,
|
|
1122
|
+
toJson: objects.toJson,
|
|
1123
|
+
ccJson: objects.ccJson,
|
|
1124
|
+
audienceJson: objects.audienceJson,
|
|
1125
|
+
communityApId: objects.communityApId,
|
|
1126
|
+
type: objects.type,
|
|
1127
|
+
endTime: objects.endTime,
|
|
1128
|
+
})
|
|
1129
|
+
.from(objects)
|
|
1130
|
+
.where(eq(objects.apId, parentId))
|
|
1131
|
+
.get()
|
|
1132
|
+
: null;
|
|
1133
|
+
if (parentId && parentObj) {
|
|
1134
|
+
if (!(await canViewerReadObjectFull(db, parentObj, attributedTo))) {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
if (
|
|
1138
|
+
isLocal(parentObj.attributedTo, baseUrl) &&
|
|
1139
|
+
(await actorSuppressesInteractionFrom(
|
|
1140
|
+
db,
|
|
1141
|
+
parentObj.attributedTo,
|
|
1142
|
+
attributedTo,
|
|
1143
|
+
))
|
|
1144
|
+
) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1062
1149
|
// Best-effort author profile fill so the surfaced boost renders with the
|
|
1063
1150
|
// author's name/icon. Cache-when-absent; a failure never blocks the persist.
|
|
1064
1151
|
const cachedAuthor = await db
|
|
@@ -1078,8 +1165,7 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1078
1165
|
}
|
|
1079
1166
|
}
|
|
1080
1167
|
|
|
1081
|
-
const
|
|
1082
|
-
await db
|
|
1168
|
+
const insertObject = db
|
|
1083
1169
|
.insert(objects)
|
|
1084
1170
|
.values({
|
|
1085
1171
|
apId: objectId,
|
|
@@ -1087,14 +1173,16 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1087
1173
|
attributedTo,
|
|
1088
1174
|
content: boundInboundContent(note.content),
|
|
1089
1175
|
summary: boundInboundSummary(note.summary),
|
|
1090
|
-
attachmentsJson:
|
|
1091
|
-
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1176
|
+
attachmentsJson: boundInboundNoteAttachmentsJson(note.attachment),
|
|
1177
|
+
tagsJson: boundInboundTagsJson(note.tag),
|
|
1178
|
+
// Stored verbatim and never remotely resolved (depth cap): a boosted
|
|
1179
|
+
// reply keeps its honest thread link even when the parent stays unknown.
|
|
1180
|
+
inReplyTo: parentId,
|
|
1094
1181
|
visibility,
|
|
1095
1182
|
toJson: boundAddressJson(note.to),
|
|
1096
1183
|
ccJson: boundAddressJson(note.cc),
|
|
1097
|
-
|
|
1184
|
+
audienceJson: JSON.stringify(audience),
|
|
1185
|
+
communityApId: communityScope.communityApId,
|
|
1098
1186
|
published: normalizeInboundTimestamp(
|
|
1099
1187
|
note.published,
|
|
1100
1188
|
new Date().toISOString(),
|
|
@@ -1103,6 +1191,18 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1103
1191
|
})
|
|
1104
1192
|
.onConflictDoNothing();
|
|
1105
1193
|
|
|
1194
|
+
if (parentId) {
|
|
1195
|
+
await runBatch(db, [
|
|
1196
|
+
insertObject,
|
|
1197
|
+
recomputeObjectReplyCount(db, parentId),
|
|
1198
|
+
// Unknown Announce targets race normal Create delivery; the no-op insert
|
|
1199
|
+
// plus recompute keeps both arrival paths idempotent and repairable.
|
|
1200
|
+
recomputeObjectReplyCount(db, objectId),
|
|
1201
|
+
]);
|
|
1202
|
+
} else {
|
|
1203
|
+
await runBatch(db, [insertObject, recomputeObjectReplyCount(db, objectId)]);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1106
1206
|
return true;
|
|
1107
1207
|
}
|
|
1108
1208
|
|
|
@@ -1110,6 +1210,17 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1110
1210
|
// Delete handler
|
|
1111
1211
|
// ---------------------------------------------------------------------------
|
|
1112
1212
|
|
|
1213
|
+
function retainedRemoteActorAliasesSql(column: SQLiteColumn, actorId: string) {
|
|
1214
|
+
return activityPubActorIdentityMatchesSql(
|
|
1215
|
+
sql`SELECT ${column} FROM ${column.table}`,
|
|
1216
|
+
actorId,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function retainedRemoteActorWhere(column: SQLiteColumn, actorId: string) {
|
|
1221
|
+
return sql`${column} IN (${retainedRemoteActorAliasesSql(column, actorId)})`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1113
1224
|
/**
|
|
1114
1225
|
* Tombstone a remote actor locally in response to a verified inbound
|
|
1115
1226
|
* Delete(Actor). Mirrors the local /me/delete teardown for the federation-facing
|
|
@@ -1122,163 +1233,258 @@ export async function fetchAndPersistAnnouncedNote(
|
|
|
1122
1233
|
async function handleRemoteActorDelete(
|
|
1123
1234
|
c: ActivityContext,
|
|
1124
1235
|
actorId: string,
|
|
1236
|
+
deleteActivityApId: string,
|
|
1125
1237
|
): Promise<void> {
|
|
1126
1238
|
const db = c.get("db");
|
|
1239
|
+
const canonicalActorId = normalizeActivityPubActorId(actorId) ?? actorId;
|
|
1240
|
+
|
|
1241
|
+
// A Delete(actor) is one authority transition: the remote identity, its
|
|
1242
|
+
// relationship authority, cached content, and every denormalized counter
|
|
1243
|
+
// must disappear together. Keeping these as independent commits made retry
|
|
1244
|
+
// unsafe: a failure after one counterpart counter decrement but before the
|
|
1245
|
+
// follow delete let the retry observe the same edge and decrement again.
|
|
1246
|
+
// D1 batch is the only atomic multi-statement primitive available here.
|
|
1247
|
+
const remoteObjectIds = () =>
|
|
1248
|
+
db
|
|
1249
|
+
.select({ id: objects.apId })
|
|
1250
|
+
.from(objects)
|
|
1251
|
+
.where(retainedRemoteActorWhere(objects.attributedTo, actorId));
|
|
1127
1252
|
|
|
1128
1253
|
// Counterpart count reconcile BEFORE dropping edges (mirrors actors.ts):
|
|
1129
1254
|
// everyone the deleted remote followed loses a follower; everyone who followed
|
|
1130
1255
|
// it loses a following. The subquery naturally scopes to LOCAL actors (remote
|
|
1131
1256
|
// actors have no `actors` row); gt(...,0) guards underflow.
|
|
1132
|
-
await db
|
|
1133
|
-
|
|
1134
|
-
.
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1257
|
+
await runBatch(db, [
|
|
1258
|
+
// Establish deletion authority in the SAME batch that tears down retained
|
|
1259
|
+
// state. A concurrent actor fetch must see either no tombstone before this
|
|
1260
|
+
// batch (and be deleted by it) or the tombstone after it (and refuse its
|
|
1261
|
+
// cache write); it can never commit between these effects.
|
|
1262
|
+
db
|
|
1263
|
+
.insert(remoteActorTombstones)
|
|
1264
|
+
.values({
|
|
1265
|
+
actorApId: canonicalActorId,
|
|
1266
|
+
deleteActivityApId,
|
|
1267
|
+
})
|
|
1268
|
+
.onConflictDoNothing(),
|
|
1269
|
+
db
|
|
1270
|
+
.update(actors)
|
|
1271
|
+
.set({
|
|
1272
|
+
followerCount: sql`MAX(0, ${actors.followerCount} - (SELECT COUNT(*) FROM ${follows} WHERE ${follows.followingApId} = ${actors.apId} AND ${retainedRemoteActorWhere(follows.followerApId, actorId)} AND ${follows.status} = 'accepted'))`,
|
|
1273
|
+
})
|
|
1274
|
+
.where(
|
|
1275
|
+
and(
|
|
1276
|
+
inArray(
|
|
1277
|
+
actors.apId,
|
|
1278
|
+
db
|
|
1279
|
+
.select({ id: follows.followingApId })
|
|
1280
|
+
.from(follows)
|
|
1281
|
+
.where(
|
|
1282
|
+
and(
|
|
1283
|
+
retainedRemoteActorWhere(follows.followerApId, actorId),
|
|
1284
|
+
eq(follows.status, "accepted"),
|
|
1285
|
+
),
|
|
1286
|
+
),
|
|
1287
|
+
),
|
|
1288
|
+
gt(actors.followerCount, 0),
|
|
1143
1289
|
),
|
|
1144
|
-
gt(actors.followerCount, 0),
|
|
1145
1290
|
),
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1291
|
+
db
|
|
1292
|
+
.update(actors)
|
|
1293
|
+
.set({
|
|
1294
|
+
followingCount: sql`MAX(0, ${actors.followingCount} - (SELECT COUNT(*) FROM ${follows} WHERE ${follows.followerApId} = ${actors.apId} AND ${retainedRemoteActorWhere(follows.followingApId, actorId)} AND ${follows.status} = 'accepted'))`,
|
|
1295
|
+
})
|
|
1296
|
+
.where(
|
|
1297
|
+
and(
|
|
1298
|
+
inArray(
|
|
1299
|
+
actors.apId,
|
|
1300
|
+
db
|
|
1301
|
+
.select({ id: follows.followerApId })
|
|
1302
|
+
.from(follows)
|
|
1303
|
+
.where(
|
|
1304
|
+
and(
|
|
1305
|
+
retainedRemoteActorWhere(follows.followingApId, actorId),
|
|
1306
|
+
eq(follows.status, "accepted"),
|
|
1307
|
+
),
|
|
1308
|
+
),
|
|
1309
|
+
),
|
|
1310
|
+
gt(actors.followingCount, 0),
|
|
1158
1311
|
),
|
|
1159
|
-
gt(actors.followingCount, 0),
|
|
1160
1312
|
),
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
// Recompute the replyCount of any LOCAL parent the remote's cached objects
|
|
1169
|
-
// replied to, counting only the replies that will REMAIN (not authored by the
|
|
1170
|
-
// deleted remote), BEFORE the cascade removes them (mirrors actors.ts).
|
|
1171
|
-
await db
|
|
1172
|
-
.update(objects)
|
|
1173
|
-
.set({
|
|
1174
|
-
replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to <> ${actorId})`,
|
|
1175
|
-
})
|
|
1176
|
-
.where(
|
|
1177
|
-
inArray(
|
|
1178
|
-
objects.apId,
|
|
1179
|
-
db
|
|
1180
|
-
.select({ id: objects.inReplyTo })
|
|
1181
|
-
.from(objects)
|
|
1182
|
-
.where(
|
|
1183
|
-
and(
|
|
1184
|
-
eq(objects.attributedTo, actorId),
|
|
1185
|
-
isNotNull(objects.inReplyTo),
|
|
1186
|
-
),
|
|
1187
|
-
),
|
|
1313
|
+
db
|
|
1314
|
+
.delete(follows)
|
|
1315
|
+
.where(
|
|
1316
|
+
or(
|
|
1317
|
+
retainedRemoteActorWhere(follows.followerApId, actorId),
|
|
1318
|
+
retainedRemoteActorWhere(follows.followingApId, actorId),
|
|
1319
|
+
),
|
|
1188
1320
|
),
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
// underflow; the subquery scopes without splicing ids (D1 param ceiling).
|
|
1199
|
-
await db
|
|
1200
|
-
.update(objects)
|
|
1201
|
-
.set({ likeCount: sql`${objects.likeCount} - 1` })
|
|
1202
|
-
.where(
|
|
1203
|
-
and(
|
|
1204
|
-
inArray(
|
|
1205
|
-
objects.apId,
|
|
1206
|
-
db
|
|
1207
|
-
.select({ id: likes.objectApId })
|
|
1208
|
-
.from(likes)
|
|
1209
|
-
.where(eq(likes.actorApId, actorId)),
|
|
1321
|
+
// Cancel recipient-addressed first-hop work while it still has actor
|
|
1322
|
+
// identity. Endpoint-aggregated work is handled by its separate recipient
|
|
1323
|
+
// attribution immediately below.
|
|
1324
|
+
db
|
|
1325
|
+
.delete(deliveryResolutions)
|
|
1326
|
+
.where(
|
|
1327
|
+
retainedRemoteActorWhere(
|
|
1328
|
+
deliveryResolutions.recipientActorApId,
|
|
1329
|
+
actorId,
|
|
1210
1330
|
),
|
|
1211
|
-
gt(objects.likeCount, 0),
|
|
1212
1331
|
),
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
.
|
|
1216
|
-
|
|
1217
|
-
|
|
1332
|
+
// Endpoint jobs created after 0029 retain every actor identity they
|
|
1333
|
+
// represent. Remove only this deleted actor's attribution first, then
|
|
1334
|
+
// cancel a recipient-aware job only if no co-recipient remains. Legacy
|
|
1335
|
+
// jobs stay fail-safe because their attribution-complete flag is 0.
|
|
1336
|
+
db
|
|
1337
|
+
.delete(deliveryEndpointRecipients)
|
|
1338
|
+
.where(
|
|
1339
|
+
retainedRemoteActorWhere(
|
|
1340
|
+
deliveryEndpointRecipients.recipientActorApId,
|
|
1341
|
+
actorId,
|
|
1342
|
+
),
|
|
1343
|
+
),
|
|
1344
|
+
db.delete(deliveryQueue).where(
|
|
1218
1345
|
and(
|
|
1219
|
-
|
|
1220
|
-
|
|
1346
|
+
eq(deliveryQueue.recipientAttributionComplete, 1),
|
|
1347
|
+
notExists(
|
|
1221
1348
|
db
|
|
1222
|
-
.select({
|
|
1223
|
-
|
|
1224
|
-
|
|
1349
|
+
.select({
|
|
1350
|
+
deliveryJobId: deliveryEndpointRecipients.deliveryJobId,
|
|
1351
|
+
})
|
|
1352
|
+
.from(deliveryEndpointRecipients)
|
|
1353
|
+
.where(
|
|
1354
|
+
eq(deliveryEndpointRecipients.deliveryJobId, deliveryQueue.id),
|
|
1355
|
+
),
|
|
1225
1356
|
),
|
|
1226
|
-
gt(objects.announceCount, 0),
|
|
1227
1357
|
),
|
|
1228
|
-
)
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1358
|
+
),
|
|
1359
|
+
|
|
1360
|
+
// Recompute the replyCount of any LOCAL parent the remote's cached objects
|
|
1361
|
+
// replied to, counting only replies that will remain after this batch.
|
|
1362
|
+
db
|
|
1363
|
+
.update(objects)
|
|
1364
|
+
.set({
|
|
1365
|
+
replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to NOT IN (${retainedRemoteActorAliasesSql(objects.attributedTo, actorId)}))`,
|
|
1366
|
+
})
|
|
1367
|
+
.where(
|
|
1234
1368
|
inArray(
|
|
1235
1369
|
objects.apId,
|
|
1236
1370
|
db
|
|
1237
|
-
.select({ id:
|
|
1238
|
-
.from(
|
|
1239
|
-
.where(
|
|
1371
|
+
.select({ id: objects.inReplyTo })
|
|
1372
|
+
.from(objects)
|
|
1373
|
+
.where(
|
|
1374
|
+
and(
|
|
1375
|
+
retainedRemoteActorWhere(objects.attributedTo, actorId),
|
|
1376
|
+
isNotNull(objects.inReplyTo),
|
|
1377
|
+
),
|
|
1378
|
+
),
|
|
1240
1379
|
),
|
|
1241
|
-
gt(objects.shareCount, 0),
|
|
1242
1380
|
),
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
await db.delete(announces).where(eq(announces.actorApId, actorId));
|
|
1247
|
-
await db.delete(bookmarks).where(eq(bookmarks.actorApId, actorId));
|
|
1248
|
-
await db.delete(storyShares).where(eq(storyShares.actorApId, actorId));
|
|
1249
|
-
await db.delete(storyVotes).where(eq(storyVotes.actorApId, actorId));
|
|
1250
|
-
await db.delete(storyViews).where(eq(storyViews.actorApId, actorId));
|
|
1251
|
-
|
|
1252
|
-
// Cascade child rows keyed by the remote's authored objects (no FK cascade on
|
|
1253
|
-
// prod D1), then the objects themselves. A fresh subquery per statement avoids
|
|
1254
|
-
// shared-AST reuse.
|
|
1255
|
-
const remoteObjectIds = () =>
|
|
1381
|
+
|
|
1382
|
+
// Reconcile counters on OTHER objects the remote interacted with before
|
|
1383
|
+
// dropping those edges. The subqueries stay bounded and D1-param-safe.
|
|
1256
1384
|
db
|
|
1257
|
-
.
|
|
1258
|
-
.
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1385
|
+
.update(objects)
|
|
1386
|
+
.set({
|
|
1387
|
+
likeCount: sql`MAX(0, ${objects.likeCount} - (SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId} AND ${retainedRemoteActorWhere(likes.actorApId, actorId)}))`,
|
|
1388
|
+
})
|
|
1389
|
+
.where(
|
|
1390
|
+
and(
|
|
1391
|
+
inArray(
|
|
1392
|
+
objects.apId,
|
|
1393
|
+
db
|
|
1394
|
+
.select({ id: likes.objectApId })
|
|
1395
|
+
.from(likes)
|
|
1396
|
+
.where(retainedRemoteActorWhere(likes.actorApId, actorId)),
|
|
1397
|
+
),
|
|
1398
|
+
gt(objects.likeCount, 0),
|
|
1399
|
+
),
|
|
1400
|
+
),
|
|
1401
|
+
db
|
|
1402
|
+
.update(objects)
|
|
1403
|
+
.set({
|
|
1404
|
+
announceCount: sql`MAX(0, ${objects.announceCount} - (SELECT COUNT(*) FROM ${announces} WHERE ${announces.objectApId} = ${objects.apId} AND ${retainedRemoteActorWhere(announces.actorApId, actorId)}))`,
|
|
1405
|
+
})
|
|
1406
|
+
.where(
|
|
1407
|
+
and(
|
|
1408
|
+
inArray(
|
|
1409
|
+
objects.apId,
|
|
1410
|
+
db
|
|
1411
|
+
.select({ id: announces.objectApId })
|
|
1412
|
+
.from(announces)
|
|
1413
|
+
.where(retainedRemoteActorWhere(announces.actorApId, actorId)),
|
|
1414
|
+
),
|
|
1415
|
+
gt(objects.announceCount, 0),
|
|
1416
|
+
),
|
|
1417
|
+
),
|
|
1418
|
+
db
|
|
1419
|
+
.update(objects)
|
|
1420
|
+
.set({
|
|
1421
|
+
shareCount: sql`MAX(0, ${objects.shareCount} - (SELECT COUNT(*) FROM ${storyShares} WHERE ${storyShares.storyApId} = ${objects.apId} AND ${retainedRemoteActorWhere(storyShares.actorApId, actorId)}))`,
|
|
1422
|
+
})
|
|
1423
|
+
.where(
|
|
1424
|
+
and(
|
|
1425
|
+
inArray(
|
|
1426
|
+
objects.apId,
|
|
1427
|
+
db
|
|
1428
|
+
.select({ id: storyShares.storyApId })
|
|
1429
|
+
.from(storyShares)
|
|
1430
|
+
.where(retainedRemoteActorWhere(storyShares.actorApId, actorId)),
|
|
1431
|
+
),
|
|
1432
|
+
gt(objects.shareCount, 0),
|
|
1433
|
+
),
|
|
1434
|
+
),
|
|
1435
|
+
|
|
1436
|
+
// Delete interaction edges the remote authored on other objects.
|
|
1437
|
+
db.delete(likes).where(retainedRemoteActorWhere(likes.actorApId, actorId)),
|
|
1438
|
+
db
|
|
1439
|
+
.delete(announces)
|
|
1440
|
+
.where(retainedRemoteActorWhere(announces.actorApId, actorId)),
|
|
1441
|
+
db
|
|
1442
|
+
.delete(bookmarks)
|
|
1443
|
+
.where(retainedRemoteActorWhere(bookmarks.actorApId, actorId)),
|
|
1444
|
+
db
|
|
1445
|
+
.delete(storyShares)
|
|
1446
|
+
.where(retainedRemoteActorWhere(storyShares.actorApId, actorId)),
|
|
1447
|
+
db
|
|
1448
|
+
.delete(storyVotes)
|
|
1449
|
+
.where(retainedRemoteActorWhere(storyVotes.actorApId, actorId)),
|
|
1450
|
+
db
|
|
1451
|
+
.delete(storyViews)
|
|
1452
|
+
.where(retainedRemoteActorWhere(storyViews.actorApId, actorId)),
|
|
1453
|
+
|
|
1454
|
+
// Cascade child rows keyed by the remote's authored objects (no FK cascade
|
|
1455
|
+
// is assumed), then remove the objects and cached identity itself. A fresh
|
|
1456
|
+
// subquery per statement avoids shared-AST reuse.
|
|
1457
|
+
db.delete(likes).where(inArray(likes.objectApId, remoteObjectIds())),
|
|
1458
|
+
db
|
|
1459
|
+
.delete(announces)
|
|
1460
|
+
.where(inArray(announces.objectApId, remoteObjectIds())),
|
|
1461
|
+
db
|
|
1462
|
+
.delete(bookmarks)
|
|
1463
|
+
.where(inArray(bookmarks.objectApId, remoteObjectIds())),
|
|
1464
|
+
db
|
|
1465
|
+
.delete(objectRecipients)
|
|
1466
|
+
.where(inArray(objectRecipients.objectApId, remoteObjectIds())),
|
|
1467
|
+
db
|
|
1468
|
+
.delete(storyVotes)
|
|
1469
|
+
.where(inArray(storyVotes.storyApId, remoteObjectIds())),
|
|
1470
|
+
db
|
|
1471
|
+
.delete(storyViews)
|
|
1472
|
+
.where(inArray(storyViews.storyApId, remoteObjectIds())),
|
|
1473
|
+
db
|
|
1474
|
+
.delete(storyShares)
|
|
1475
|
+
.where(inArray(storyShares.storyApId, remoteObjectIds())),
|
|
1476
|
+
db
|
|
1477
|
+
.delete(objects)
|
|
1478
|
+
.where(retainedRemoteActorWhere(objects.attributedTo, actorId)),
|
|
1479
|
+
db
|
|
1480
|
+
.delete(actorCache)
|
|
1481
|
+
.where(retainedRemoteActorWhere(actorCache.apId, actorId)),
|
|
1482
|
+
db
|
|
1483
|
+
.delete(remoteActorFetchFailures)
|
|
1484
|
+
.where(
|
|
1485
|
+
retainedRemoteActorWhere(remoteActorFetchFailures.actorApId, actorId),
|
|
1486
|
+
),
|
|
1487
|
+
]);
|
|
1282
1488
|
|
|
1283
1489
|
log.info("Processed inbound Delete(actor)", {
|
|
1284
1490
|
event: "ap.delete.actor",
|
|
@@ -1318,14 +1524,14 @@ export async function handleDelete(c: ActivityContext, activity: Activity) {
|
|
|
1318
1524
|
// (same origin), so an object that equals the verified actor is owned by the
|
|
1319
1525
|
// signer. Tombstone the remote locally so a stale profile + dangling follow
|
|
1320
1526
|
// edge + cached content do not survive indefinitely.
|
|
1321
|
-
if (objectId
|
|
1322
|
-
await handleRemoteActorDelete(c, actorId);
|
|
1527
|
+
if (isSameActivityPubActor(objectId, actorId)) {
|
|
1528
|
+
await handleRemoteActorDelete(c, actorId, activity.id ?? actorId);
|
|
1323
1529
|
}
|
|
1324
1530
|
return;
|
|
1325
1531
|
}
|
|
1326
1532
|
|
|
1327
1533
|
// Verify actor owns the object before deleting
|
|
1328
|
-
if (delObj.attributedTo
|
|
1534
|
+
if (!isSameActivityPubActor(delObj.attributedTo, actorId)) {
|
|
1329
1535
|
log.warn("Delete rejected: actor does not own object", {
|
|
1330
1536
|
event: "ap.delete.actor_ownership_mismatch",
|
|
1331
1537
|
actor: actorId,
|
|
@@ -1413,8 +1619,11 @@ export async function handleUpdate(
|
|
|
1413
1619
|
// mirroring the actor==object self-update contract). The remote document is
|
|
1414
1620
|
// re-fetched from origin (never trusted from the wire) so a spoofed Update
|
|
1415
1621
|
// body cannot poison the cache.
|
|
1416
|
-
if (
|
|
1417
|
-
|
|
1622
|
+
if (
|
|
1623
|
+
isActorTypeUpdate(object.type) ||
|
|
1624
|
+
isSameActivityPubActor(objectId, actor)
|
|
1625
|
+
) {
|
|
1626
|
+
if (!isSameActivityPubActor(objectId, actor)) {
|
|
1418
1627
|
log.warn("Update(actor) rejected: object id does not match actor", {
|
|
1419
1628
|
event: "ap.update.actor_self_mismatch",
|
|
1420
1629
|
actor,
|
|
@@ -1430,7 +1639,7 @@ export async function handleUpdate(
|
|
|
1430
1639
|
const cached = await db
|
|
1431
1640
|
.select({ lastFetchedAt: actorCache.lastFetchedAt })
|
|
1432
1641
|
.from(actorCache)
|
|
1433
|
-
.where(eq(actorCache.apId,
|
|
1642
|
+
.where(eq(actorCache.apId, actor))
|
|
1434
1643
|
.get();
|
|
1435
1644
|
if (cached?.lastFetchedAt) {
|
|
1436
1645
|
const age = Date.now() - new Date(cached.lastFetchedAt).getTime();
|
|
@@ -1441,45 +1650,369 @@ export async function handleUpdate(
|
|
|
1441
1650
|
) {
|
|
1442
1651
|
log.debug("Update(actor) re-fetch skipped: within cooldown", {
|
|
1443
1652
|
event: "ap.update.actor_refetch_cooldown",
|
|
1444
|
-
actor
|
|
1653
|
+
actor,
|
|
1445
1654
|
ageMs: age,
|
|
1446
1655
|
});
|
|
1447
1656
|
return;
|
|
1448
1657
|
}
|
|
1449
1658
|
}
|
|
1450
|
-
await refreshActorCache(db,
|
|
1659
|
+
await refreshActorCache(db, actor);
|
|
1451
1660
|
return;
|
|
1452
1661
|
}
|
|
1453
1662
|
|
|
1454
1663
|
const existing = await db
|
|
1455
|
-
.select({
|
|
1664
|
+
.select({
|
|
1665
|
+
attributedTo: objects.attributedTo,
|
|
1666
|
+
inReplyTo: objects.inReplyTo,
|
|
1667
|
+
type: objects.type,
|
|
1668
|
+
attachmentsJson: objects.attachmentsJson,
|
|
1669
|
+
published: objects.published,
|
|
1670
|
+
endTime: objects.endTime,
|
|
1671
|
+
communityApId: objects.communityApId,
|
|
1672
|
+
})
|
|
1456
1673
|
.from(objects)
|
|
1457
1674
|
.where(eq(objects.apId, objectId))
|
|
1458
1675
|
.get();
|
|
1459
|
-
if (!existing || existing.attributedTo
|
|
1676
|
+
if (!existing || !isSameActivityPubActor(existing.attributedTo, actor)) {
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Story is deliberately handled before Note: Yurucommu emits
|
|
1681
|
+
// type=["Story","Note"] for interoperability, but its durable projection is
|
|
1682
|
+
// not a Note attachment array. The old generic branch rewrote
|
|
1683
|
+
// attachments_json/content into the wrong shape and corrupted the Story UI.
|
|
1684
|
+
if (existing.type === "Story" || isStoryType(object.type)) {
|
|
1685
|
+
if (existing.type !== "Story" || !isStoryType(object.type)) {
|
|
1686
|
+
log.warn("Update rejected: object type does not match stored Story", {
|
|
1687
|
+
event: "ap.update.story_type_mismatch",
|
|
1688
|
+
actor,
|
|
1689
|
+
objectId,
|
|
1690
|
+
});
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (await ownerSuppressesInboundActor(db, actor)) return;
|
|
1694
|
+
|
|
1695
|
+
const now = new Date().toISOString();
|
|
1696
|
+
const endTime = normalizeInboundStoryUpdateEndTime(
|
|
1697
|
+
existing.published,
|
|
1698
|
+
existing.endTime,
|
|
1699
|
+
object.endTime,
|
|
1700
|
+
now,
|
|
1701
|
+
);
|
|
1702
|
+
if (!endTime) {
|
|
1703
|
+
log.debug("Update(Story) dropped: invalid or expired lifetime", {
|
|
1704
|
+
event: "ap.update.story_expired",
|
|
1705
|
+
actor,
|
|
1706
|
+
objectId,
|
|
1707
|
+
});
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// A Story's scope is fixed at creation. Re-authorize the retained community
|
|
1712
|
+
// on every Update (membership/ban policy may have changed) and fold any new
|
|
1713
|
+
// addressing into the same resolution. A second local community is then
|
|
1714
|
+
// ambiguous and rejected; a valid Update never widens or moves scope.
|
|
1715
|
+
const hasAddressingUpdate = declaresStoryAddressing(activity, object);
|
|
1716
|
+
if (hasAddressingUpdate || existing.communityApId !== null) {
|
|
1717
|
+
const storyAddressing = storyAddressedCollections(activity, object);
|
|
1718
|
+
if (storyAddressing.overflow) {
|
|
1719
|
+
log.warn("Update(Story) rejected: too many addressing entries", {
|
|
1720
|
+
event: "ap.update.story_addressing_overflow",
|
|
1721
|
+
actor,
|
|
1722
|
+
objectId,
|
|
1723
|
+
});
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const scopeAddresses = existing.communityApId
|
|
1727
|
+
? [...new Set([...storyAddressing.addresses, existing.communityApId])]
|
|
1728
|
+
: storyAddressing.addresses;
|
|
1729
|
+
const communityScope = await resolveInboundCommunityScope(
|
|
1730
|
+
db,
|
|
1731
|
+
actor,
|
|
1732
|
+
scopeAddresses,
|
|
1733
|
+
);
|
|
1734
|
+
if (
|
|
1735
|
+
!communityScope.allowed ||
|
|
1736
|
+
communityScope.communityApId !== existing.communityApId
|
|
1737
|
+
) {
|
|
1738
|
+
log.warn("Update(Story) rejected: scope is unauthorized or changed", {
|
|
1739
|
+
event: "ap.update.story_scope_mismatch",
|
|
1740
|
+
actor,
|
|
1741
|
+
objectId,
|
|
1742
|
+
existingCommunityApId: existing.communityApId,
|
|
1743
|
+
});
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const projection = buildInboundStoryUpdateProjection(
|
|
1749
|
+
object,
|
|
1750
|
+
existing.attachmentsJson,
|
|
1751
|
+
);
|
|
1752
|
+
if (!projection) {
|
|
1753
|
+
log.warn("Update(Story) rejected: invalid story projection", {
|
|
1754
|
+
event: "ap.update.story_invalid_projection",
|
|
1755
|
+
actor,
|
|
1756
|
+
objectId,
|
|
1757
|
+
});
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const projectionChanged = hasStoryProjectionUpdate(object);
|
|
1762
|
+
const updateStory = db
|
|
1763
|
+
.update(objects)
|
|
1764
|
+
.set({
|
|
1765
|
+
attachmentsJson: projectionChanged ? projection.json : undefined,
|
|
1766
|
+
endTime,
|
|
1767
|
+
updated: now,
|
|
1768
|
+
})
|
|
1769
|
+
.where(eq(objects.apId, objectId));
|
|
1770
|
+
if (object.overlays !== undefined) {
|
|
1771
|
+
// Poll votes are indexed only by option position. Replacing/clearing the
|
|
1772
|
+
// overlay list must clear old votes in the same D1 batch, otherwise an old
|
|
1773
|
+
// option 0 is silently counted for a different new option 0.
|
|
1774
|
+
await runBatch(db, [
|
|
1775
|
+
updateStory,
|
|
1776
|
+
db.delete(storyVotes).where(eq(storyVotes.storyApId, objectId)),
|
|
1777
|
+
]);
|
|
1778
|
+
} else {
|
|
1779
|
+
await updateStory;
|
|
1780
|
+
}
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1460
1783
|
|
|
1461
1784
|
// Update object content
|
|
1462
|
-
if (typeIncludes(object.type, "Note")) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1785
|
+
if (existing.type === "Note" && typeIncludes(object.type, "Note")) {
|
|
1786
|
+
// Update is instance-dispatched and therefore has no single recipient row.
|
|
1787
|
+
// Yurucommu's default deployment has one local owner, so the same owner-wide
|
|
1788
|
+
// block/mute policy used by Story Update must stop a newly suppressed remote
|
|
1789
|
+
// actor from replacing already-retained Note content or reach.
|
|
1790
|
+
if (await ownerSuppressesInboundActor(db, actor)) return;
|
|
1791
|
+
|
|
1792
|
+
const addressingContract = collectBoundedInboundAddresses([object]);
|
|
1793
|
+
if (!addressingContract.ok) {
|
|
1794
|
+
log.warn("Update(Note) rejected: invalid addressing projection", {
|
|
1795
|
+
event: "ap.update.note_addressing_invalid",
|
|
1796
|
+
actor,
|
|
1797
|
+
objectId,
|
|
1798
|
+
reason: addressingContract.reason,
|
|
1799
|
+
});
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const replyTarget = validateInboundReplyTarget(object.inReplyTo);
|
|
1804
|
+
if (!replyTarget.ok) {
|
|
1805
|
+
log.warn("Update(Note) rejected: invalid reply target", {
|
|
1806
|
+
event: "ap.update.note_reply_target_invalid",
|
|
1807
|
+
actor,
|
|
1808
|
+
objectId,
|
|
1809
|
+
reason: replyTarget.reason,
|
|
1810
|
+
});
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Content and reach are one authority decision. A remote author can narrow
|
|
1815
|
+
// an existing public Note to followers/direct in the same Update; applying
|
|
1816
|
+
// only its new body would leave that private content readable through the
|
|
1817
|
+
// stale public single-object gate. Treat the presence of either addressing
|
|
1818
|
+
// field as a complete reach update (an omitted counterpart is empty), while
|
|
1819
|
+
// preserving both old fields for peers that send a legacy content-only
|
|
1820
|
+
// partial Update.
|
|
1821
|
+
const hasAddressingUpdate = declaresAddressing(object);
|
|
1822
|
+
const updatedAddressing = hasAddressingUpdate
|
|
1823
|
+
? noteAddressing(object)
|
|
1824
|
+
: undefined;
|
|
1825
|
+
const hasAudienceUpdate = object.audience !== undefined;
|
|
1826
|
+
const updatedAudience = hasAudienceUpdate
|
|
1827
|
+
? normalizedObjectAudience(activity, object)
|
|
1828
|
+
: undefined;
|
|
1829
|
+
const communityScope = hasAudienceUpdate
|
|
1830
|
+
? await resolveInboundCommunityScope(db, actor, updatedAudience ?? [])
|
|
1831
|
+
: existing.communityApId
|
|
1832
|
+
? await resolveInboundCommunityScope(db, actor, [
|
|
1833
|
+
existing.communityApId,
|
|
1834
|
+
])
|
|
1835
|
+
: { allowed: true as const, communityApId: null };
|
|
1836
|
+
if (
|
|
1837
|
+
!communityScope.allowed ||
|
|
1838
|
+
(!hasAudienceUpdate &&
|
|
1839
|
+
communityScope.communityApId !== existing.communityApId)
|
|
1840
|
+
) {
|
|
1841
|
+
log.warn("Update(Note) rejected: actor cannot project into community", {
|
|
1842
|
+
event: "ap.update.note_community_unauthorized",
|
|
1843
|
+
actor,
|
|
1844
|
+
objectId,
|
|
1845
|
+
});
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const hasThreadUpdate = replyTarget.parentId !== undefined;
|
|
1850
|
+
const updatedParentId = replyTarget.parentId;
|
|
1851
|
+
if (updatedParentId === objectId) {
|
|
1852
|
+
log.warn("Update(Note) rejected: object cannot reply to itself", {
|
|
1853
|
+
event: "ap.update.note_self_reply",
|
|
1854
|
+
actor,
|
|
1855
|
+
objectId,
|
|
1856
|
+
});
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (updatedParentId) {
|
|
1860
|
+
const parent = await db
|
|
1861
|
+
.select({
|
|
1862
|
+
apId: objects.apId,
|
|
1863
|
+
attributedTo: objects.attributedTo,
|
|
1864
|
+
visibility: objects.visibility,
|
|
1865
|
+
toJson: objects.toJson,
|
|
1866
|
+
ccJson: objects.ccJson,
|
|
1867
|
+
audienceJson: objects.audienceJson,
|
|
1868
|
+
communityApId: objects.communityApId,
|
|
1869
|
+
type: objects.type,
|
|
1870
|
+
endTime: objects.endTime,
|
|
1871
|
+
deletedAt: objects.deletedAt,
|
|
1872
|
+
})
|
|
1873
|
+
.from(objects)
|
|
1874
|
+
.where(eq(objects.apId, updatedParentId))
|
|
1875
|
+
.get();
|
|
1876
|
+
if (
|
|
1877
|
+
parent &&
|
|
1878
|
+
(parent.deletedAt !== null ||
|
|
1879
|
+
!(await canViewerReadObjectFull(db, parent, actor)) ||
|
|
1880
|
+
(isLocal(parent.attributedTo, c.env.APP_URL) &&
|
|
1881
|
+
(await actorSuppressesInteractionFrom(
|
|
1882
|
+
db,
|
|
1883
|
+
parent.attributedTo,
|
|
1884
|
+
actor,
|
|
1885
|
+
))))
|
|
1886
|
+
) {
|
|
1887
|
+
log.warn("Update(Note) rejected: reply parent is not readable", {
|
|
1888
|
+
event: "ap.update.note_parent_unreadable",
|
|
1889
|
+
actor,
|
|
1890
|
+
objectId,
|
|
1891
|
+
parentId: updatedParentId,
|
|
1892
|
+
});
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const updatedVisibility = updatedAddressing
|
|
1898
|
+
? isDirectShapedNote(updatedAddressing)
|
|
1899
|
+
? "direct"
|
|
1900
|
+
: classifyInboundNoteVisibility(updatedAddressing)
|
|
1465
1901
|
: undefined;
|
|
1466
|
-
|
|
1902
|
+
const projectedRecipients = updatedAddressing
|
|
1903
|
+
? specificRecipientAddresses(updatedAddressing)
|
|
1904
|
+
: [];
|
|
1905
|
+
// DM conversation ids are pair authority. Never carry an old recipient's
|
|
1906
|
+
// thread id across a re-address; only a single specific recipient has a
|
|
1907
|
+
// 1:1 conversation in Yurucommu's model. Public/followers/multi-recipient/
|
|
1908
|
+
// empty reach clears the old DM conversation.
|
|
1909
|
+
const updatedConversation = hasAddressingUpdate
|
|
1910
|
+
? updatedVisibility === "direct" && projectedRecipients.length === 1
|
|
1911
|
+
? getConversationId(c.env.APP_URL, actor, projectedRecipients[0])
|
|
1912
|
+
: null
|
|
1913
|
+
: undefined;
|
|
1914
|
+
const updateObject = db
|
|
1467
1915
|
.update(objects)
|
|
1468
1916
|
.set({
|
|
1469
1917
|
content:
|
|
1470
|
-
|
|
1471
|
-
?
|
|
1918
|
+
object.content !== undefined
|
|
1919
|
+
? boundInboundContent(object.content)
|
|
1472
1920
|
: undefined,
|
|
1473
1921
|
summary:
|
|
1474
|
-
|
|
1475
|
-
?
|
|
1922
|
+
object.summary !== undefined
|
|
1923
|
+
? boundInboundSummary(object.summary)
|
|
1924
|
+
: undefined,
|
|
1925
|
+
attachmentsJson:
|
|
1926
|
+
object.attachment !== undefined
|
|
1927
|
+
? boundInboundNoteAttachmentsJson(object.attachment)
|
|
1476
1928
|
: undefined,
|
|
1477
|
-
|
|
1478
|
-
|
|
1929
|
+
tagsJson:
|
|
1930
|
+
object.tag !== undefined
|
|
1931
|
+
? boundInboundTagsJson(object.tag)
|
|
1932
|
+
: undefined,
|
|
1933
|
+
visibility: updatedVisibility,
|
|
1934
|
+
// bto/bcc are deliberately absent: their recipients are private and
|
|
1935
|
+
// represented only by the indexed recipient projection below.
|
|
1936
|
+
toJson: updatedAddressing
|
|
1937
|
+
? boundAddressJson(updatedAddressing.to)
|
|
1938
|
+
: undefined,
|
|
1939
|
+
ccJson: updatedAddressing
|
|
1940
|
+
? boundAddressJson(updatedAddressing.cc)
|
|
1941
|
+
: undefined,
|
|
1942
|
+
audienceJson: hasAudienceUpdate
|
|
1943
|
+
? JSON.stringify(updatedAudience)
|
|
1944
|
+
: undefined,
|
|
1945
|
+
communityApId: hasAudienceUpdate
|
|
1946
|
+
? communityScope.communityApId
|
|
1479
1947
|
: undefined,
|
|
1948
|
+
inReplyTo: hasThreadUpdate ? (updatedParentId ?? null) : undefined,
|
|
1949
|
+
conversation: updatedConversation,
|
|
1480
1950
|
updated: new Date().toISOString(),
|
|
1481
1951
|
})
|
|
1482
1952
|
.where(eq(objects.apId, objectId));
|
|
1953
|
+
|
|
1954
|
+
// `object_recipients(type=to)` is the indexed DM-read authority used by
|
|
1955
|
+
// contacts, requests, unread counts, and conversation discovery. Updating
|
|
1956
|
+
// only toJson would revoke canonical object GET while the old recipient
|
|
1957
|
+
// still received the NEW private body in their contact preview. Replace the
|
|
1958
|
+
// projection in the same D1 batch as the object row so neither old nor new
|
|
1959
|
+
// reach can be observed half-applied. insertMany keeps every statement
|
|
1960
|
+
// below D1's parameter ceiling for the bounded 64-address input.
|
|
1961
|
+
const recipientProjectionStatements = hasAddressingUpdate
|
|
1962
|
+
? [
|
|
1963
|
+
db
|
|
1964
|
+
.delete(objectRecipients)
|
|
1965
|
+
.where(
|
|
1966
|
+
and(
|
|
1967
|
+
eq(objectRecipients.objectApId, objectId),
|
|
1968
|
+
eq(objectRecipients.type, "to"),
|
|
1969
|
+
),
|
|
1970
|
+
),
|
|
1971
|
+
...insertMany(
|
|
1972
|
+
db,
|
|
1973
|
+
objectRecipients,
|
|
1974
|
+
projectedRecipients.map((recipientApId) => ({
|
|
1975
|
+
objectApId: objectId,
|
|
1976
|
+
recipientApId,
|
|
1977
|
+
type: "to",
|
|
1978
|
+
})),
|
|
1979
|
+
{ conflict: "ignore" },
|
|
1980
|
+
),
|
|
1981
|
+
]
|
|
1982
|
+
: [];
|
|
1983
|
+
|
|
1984
|
+
// A reply edge and both denormalized parent counters are one mutation.
|
|
1985
|
+
// Recompute (rather than increment/decrement) after the child UPDATE so a
|
|
1986
|
+
// duplicate/retry converges without double-counting. The old and new parent
|
|
1987
|
+
// set has at most two entries, keeping the whole projection far below D1's
|
|
1988
|
+
// 50-statement batch ceiling even at the 64-recipient addressing bound.
|
|
1989
|
+
const parentIdsToRecompute = hasThreadUpdate
|
|
1990
|
+
? [
|
|
1991
|
+
...new Set(
|
|
1992
|
+
[existing.inReplyTo, updatedParentId ?? null].filter(
|
|
1993
|
+
(id): id is string => typeof id === "string" && id.length > 0,
|
|
1994
|
+
),
|
|
1995
|
+
),
|
|
1996
|
+
]
|
|
1997
|
+
: [];
|
|
1998
|
+
const parentCounterStatements = parentIdsToRecompute.map((parentId) =>
|
|
1999
|
+
db
|
|
2000
|
+
.update(objects)
|
|
2001
|
+
.set({
|
|
2002
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
2003
|
+
})
|
|
2004
|
+
.where(eq(objects.apId, parentId)),
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
const projectionStatements = [
|
|
2008
|
+
...recipientProjectionStatements,
|
|
2009
|
+
...parentCounterStatements,
|
|
2010
|
+
];
|
|
2011
|
+
if (projectionStatements.length === 0) {
|
|
2012
|
+
await updateObject;
|
|
2013
|
+
} else {
|
|
2014
|
+
await runBatch(db, [updateObject, ...projectionStatements]);
|
|
2015
|
+
}
|
|
1483
2016
|
}
|
|
1484
2017
|
}
|
|
1485
2018
|
|
|
@@ -1493,14 +2026,15 @@ export async function handleMove(
|
|
|
1493
2026
|
actor: string,
|
|
1494
2027
|
) {
|
|
1495
2028
|
const db = c.get("db");
|
|
1496
|
-
const
|
|
2029
|
+
const claimedOldActorApId = getActivityObjectId(activity);
|
|
1497
2030
|
const newActorApId = getActivityTargetId(activity);
|
|
1498
|
-
if (!
|
|
2031
|
+
if (!claimedOldActorApId || !newActorApId) return;
|
|
1499
2032
|
|
|
1500
2033
|
// Only accept self-move. Signature verification already ensures the request is signed,
|
|
1501
2034
|
// but we also require Move.object to match Move.actor (defense-in-depth).
|
|
1502
|
-
if (
|
|
1503
|
-
if (
|
|
2035
|
+
if (!isSameActivityPubActor(claimedOldActorApId, actor)) return;
|
|
2036
|
+
if (isSameActivityPubActor(actor, newActorApId)) return;
|
|
2037
|
+
const oldActorApId = actor;
|
|
1504
2038
|
|
|
1505
2039
|
if (!isSafeRemoteUrl(newActorApId)) {
|
|
1506
2040
|
log.warn("Blocked unsafe Move target", {
|
|
@@ -1511,6 +2045,31 @@ export async function handleMove(
|
|
|
1511
2045
|
return;
|
|
1512
2046
|
}
|
|
1513
2047
|
|
|
2048
|
+
const baseUrl = c.env.APP_URL;
|
|
2049
|
+
const refollowPrefix = await moveRefollowPrefix({
|
|
2050
|
+
baseUrl,
|
|
2051
|
+
inboundActivityId: activity.id,
|
|
2052
|
+
oldActorApId,
|
|
2053
|
+
newActorApId,
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
// The graph + outbound Follow activities commit atomically below, while the
|
|
2057
|
+
// external Queue send necessarily happens afterwards. If that send threw,
|
|
2058
|
+
// the inbox row remains processed=0 and a peer retry reaches this handler.
|
|
2059
|
+
// Resume from the durable activity namespace BEFORE re-fetching the alias:
|
|
2060
|
+
// the old edges are already gone, and a temporary destination fetch outage
|
|
2061
|
+
// must not turn a retryable Queue failure into a silently completed no-op.
|
|
2062
|
+
if (
|
|
2063
|
+
(await enqueuePersistedMoveRefollows({
|
|
2064
|
+
db,
|
|
2065
|
+
env: c.env,
|
|
2066
|
+
newActorApId,
|
|
2067
|
+
refollowPrefix,
|
|
2068
|
+
})) > 0
|
|
2069
|
+
) {
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1514
2073
|
// SECURITY (account-migration follow-graph hijack): a signed Move only proves
|
|
1515
2074
|
// the OLD actor consents to move; it does NOT prove the destination is the same
|
|
1516
2075
|
// person. Without verifying the destination's `alsoKnownAs` back-reference, a
|
|
@@ -1536,266 +2095,22 @@ export async function handleMove(
|
|
|
1536
2095
|
// Refresh/cache the new actor document (best-effort).
|
|
1537
2096
|
await refreshActorCache(db, newActorApId);
|
|
1538
2097
|
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
createdAt: follows.createdAt,
|
|
1546
|
-
acceptedAt: follows.acceptedAt,
|
|
1547
|
-
})
|
|
1548
|
-
.from(follows)
|
|
1549
|
-
.where(eq(follows.followerApId, oldActorApId));
|
|
1550
|
-
|
|
1551
|
-
const followingRows = await db
|
|
1552
|
-
.select({
|
|
1553
|
-
followerApId: follows.followerApId,
|
|
1554
|
-
status: follows.status,
|
|
1555
|
-
activityApId: follows.activityApId,
|
|
1556
|
-
createdAt: follows.createdAt,
|
|
1557
|
-
acceptedAt: follows.acceptedAt,
|
|
1558
|
-
})
|
|
1559
|
-
.from(follows)
|
|
1560
|
-
.where(eq(follows.followingApId, oldActorApId));
|
|
1561
|
-
|
|
1562
|
-
const followerTargets = followerRows.map((row) => row.followingApId);
|
|
1563
|
-
const followingSources = followingRows.map((row) => row.followerApId);
|
|
1564
|
-
|
|
1565
|
-
const existingFollowerPairs =
|
|
1566
|
-
followerTargets.length > 0
|
|
1567
|
-
? await db
|
|
1568
|
-
.select({ followingApId: follows.followingApId })
|
|
1569
|
-
.from(follows)
|
|
1570
|
-
.where(
|
|
1571
|
-
and(
|
|
1572
|
-
eq(follows.followerApId, newActorApId),
|
|
1573
|
-
// Subquery, not `inArray(followerTargets)`: the old actor's follow
|
|
1574
|
-
// graph can exceed D1's 100-bound-parameter ceiling. Same set as
|
|
1575
|
-
// followerTargets (the old actor's followees).
|
|
1576
|
-
inArray(
|
|
1577
|
-
follows.followingApId,
|
|
1578
|
-
db
|
|
1579
|
-
.select({ id: follows.followingApId })
|
|
1580
|
-
.from(follows)
|
|
1581
|
-
.where(eq(follows.followerApId, oldActorApId)),
|
|
1582
|
-
),
|
|
1583
|
-
),
|
|
1584
|
-
)
|
|
1585
|
-
: [];
|
|
1586
|
-
const existingFollowingPairs =
|
|
1587
|
-
followingSources.length > 0
|
|
1588
|
-
? await db
|
|
1589
|
-
.select({ followerApId: follows.followerApId })
|
|
1590
|
-
.from(follows)
|
|
1591
|
-
.where(
|
|
1592
|
-
and(
|
|
1593
|
-
// Subquery, not `inArray(followingSources)`: the old actor's
|
|
1594
|
-
// follower graph can exceed D1's 100-bound-parameter ceiling. Same
|
|
1595
|
-
// set as followingSources (the old actor's followers).
|
|
1596
|
-
inArray(
|
|
1597
|
-
follows.followerApId,
|
|
1598
|
-
db
|
|
1599
|
-
.select({ id: follows.followerApId })
|
|
1600
|
-
.from(follows)
|
|
1601
|
-
.where(eq(follows.followingApId, oldActorApId)),
|
|
1602
|
-
),
|
|
1603
|
-
eq(follows.followingApId, newActorApId),
|
|
1604
|
-
),
|
|
1605
|
-
)
|
|
1606
|
-
: [];
|
|
1607
|
-
|
|
1608
|
-
const existingFollowerTargetSet = new Set(
|
|
1609
|
-
existingFollowerPairs.map((row) => row.followingApId),
|
|
1610
|
-
);
|
|
1611
|
-
const existingFollowingSourceSet = new Set(
|
|
1612
|
-
existingFollowingPairs.map((row) => row.followerApId),
|
|
1613
|
-
);
|
|
1614
|
-
|
|
1615
|
-
// Drop self-edges in addition to the existing-pair dedup: if the old and new
|
|
1616
|
-
// actor were already connected (old followed/was-followed-by new, or vice
|
|
1617
|
-
// versa), rewriting the endpoint to the new actor would produce a row where
|
|
1618
|
-
// followerApId === followingApId (a self-follow). Filter those out so the
|
|
1619
|
-
// migration never materializes a self-follow.
|
|
1620
|
-
const followerRewrites = followerRows
|
|
1621
|
-
.filter(
|
|
1622
|
-
(row) =>
|
|
1623
|
-
!existingFollowerTargetSet.has(row.followingApId) &&
|
|
1624
|
-
row.followingApId !== newActorApId,
|
|
1625
|
-
)
|
|
1626
|
-
.map((row) => ({
|
|
1627
|
-
followerApId: newActorApId,
|
|
1628
|
-
followingApId: row.followingApId,
|
|
1629
|
-
status: row.status,
|
|
1630
|
-
activityApId: row.activityApId,
|
|
1631
|
-
createdAt: row.createdAt,
|
|
1632
|
-
acceptedAt: row.acceptedAt,
|
|
1633
|
-
}));
|
|
1634
|
-
// For followers that are LOCAL to this instance, a bare edge rewrite is not
|
|
1635
|
-
// enough: the destination server has no record of the follow, so it would
|
|
1636
|
-
// never deliver the migrated account's posts (the local user's following list
|
|
1637
|
-
// would point at the new actor but silently receive nothing). Re-issue a
|
|
1638
|
-
// fresh, *pending* Follow to the new actor and enqueue outbound delivery —
|
|
1639
|
-
// the standard Mastodon "follow the move target on the user's behalf"
|
|
1640
|
-
// behavior; the destination's Accept flips it to accepted. Remote followers
|
|
1641
|
-
// are left as a plain edge rewrite: re-establishing their follow is their own
|
|
1642
|
-
// server's responsibility.
|
|
1643
|
-
const baseUrl = c.env.APP_URL;
|
|
1644
|
-
const localReFollows: { followerApId: string; followId: string }[] = [];
|
|
1645
|
-
const followingRewrites = followingRows
|
|
1646
|
-
.filter(
|
|
1647
|
-
(row) =>
|
|
1648
|
-
!existingFollowingSourceSet.has(row.followerApId) &&
|
|
1649
|
-
row.followerApId !== newActorApId,
|
|
1650
|
-
)
|
|
1651
|
-
.map((row) => {
|
|
1652
|
-
if (isLocal(row.followerApId, baseUrl)) {
|
|
1653
|
-
const followId = activityApId(baseUrl, generateId());
|
|
1654
|
-
localReFollows.push({ followerApId: row.followerApId, followId });
|
|
1655
|
-
return {
|
|
1656
|
-
followerApId: row.followerApId,
|
|
1657
|
-
followingApId: newActorApId,
|
|
1658
|
-
status: "pending",
|
|
1659
|
-
activityApId: followId,
|
|
1660
|
-
createdAt: row.createdAt,
|
|
1661
|
-
acceptedAt: null,
|
|
1662
|
-
};
|
|
1663
|
-
}
|
|
1664
|
-
return {
|
|
1665
|
-
followerApId: row.followerApId,
|
|
1666
|
-
followingApId: newActorApId,
|
|
1667
|
-
status: row.status,
|
|
1668
|
-
activityApId: row.activityApId,
|
|
1669
|
-
createdAt: row.createdAt,
|
|
1670
|
-
acceptedAt: row.acceptedAt,
|
|
1671
|
-
};
|
|
1672
|
-
});
|
|
1673
|
-
|
|
1674
|
-
// LOCAL followers whose ACCEPTED edge to the old actor we are about to delete.
|
|
1675
|
-
// Each such edge was counted in that follower's followingCount; the re-issued
|
|
1676
|
-
// Follow to the new actor is created PENDING (uncounted) and only re-adds the
|
|
1677
|
-
// +1 when the destination Accepts (handleAccept). So the old +1 must be removed
|
|
1678
|
-
// now, otherwise the eventual Accept stacks a second +1 on the never-removed
|
|
1679
|
-
// old count → a permanent over-count of 1 per migrated follow. Decrementing at
|
|
1680
|
-
// delete time is correct in every Accept-timing case: during the pending window
|
|
1681
|
-
// the follower counts 0 of this relationship (right — it is pending), after the
|
|
1682
|
-
// Accept it is back to 1, and if the Accept never arrives it stays decremented
|
|
1683
|
-
// (right — the edge is perpetually pending). Remote followers' counts are not
|
|
1684
|
-
// ours to manage; only local followingCount is authoritative here.
|
|
1685
|
-
const localAcceptedFollowerApIds = Array.from(
|
|
1686
|
-
new Set(
|
|
1687
|
-
followingRows
|
|
1688
|
-
.filter(
|
|
1689
|
-
(row) =>
|
|
1690
|
-
row.status === "accepted" && isLocal(row.followerApId, baseUrl),
|
|
1691
|
-
)
|
|
1692
|
-
.map((row) => row.followerApId),
|
|
1693
|
-
),
|
|
1694
|
-
);
|
|
1695
|
-
|
|
1696
|
-
// Symmetric to the above, on the FOLLOWEE side: the old actor's ACCEPTED follow
|
|
1697
|
-
// of a LOCAL actor L incremented L.followerCount (handleFollow). When the
|
|
1698
|
-
// (old→L) rewrite is DROPPED as a duplicate (the NEW actor already follows L,
|
|
1699
|
-
// i.e. L ∈ existingFollowerTargetSet) or as a self-edge (L === newActor), the
|
|
1700
|
-
// delete below still removes (old→L) but NO rewrite re-adds an (new→L) edge for
|
|
1701
|
-
// it — so L would keep a permanent +1 over-count. Decrement those dropped,
|
|
1702
|
-
// accepted, local followees' followerCount once (the non-dropped case is
|
|
1703
|
-
// count-preserving: delete old→L + insert new→L). gt(>0) guards underflow.
|
|
1704
|
-
const droppedAcceptedLocalFolloweeApIds = Array.from(
|
|
1705
|
-
new Set(
|
|
1706
|
-
followerRows
|
|
1707
|
-
.filter(
|
|
1708
|
-
(row) =>
|
|
1709
|
-
row.status === "accepted" &&
|
|
1710
|
-
isLocal(row.followingApId, baseUrl) &&
|
|
1711
|
-
(existingFollowerTargetSet.has(row.followingApId) ||
|
|
1712
|
-
row.followingApId === newActorApId),
|
|
1713
|
-
)
|
|
1714
|
-
.map((row) => row.followingApId),
|
|
1715
|
-
),
|
|
1716
|
-
);
|
|
1717
|
-
|
|
1718
|
-
// Co-commit the four edge mutations + the per-follower followingCount
|
|
1719
|
-
// decrements in ONE atomic batch. D1 has no interactive transactions, and the
|
|
1720
|
-
// OLD sequential form was non-convergent: a crash between "delete old edges"
|
|
1721
|
-
// and "decrement" left the old edges gone, so a re-dispatch (the row is still
|
|
1722
|
-
// processed=0) re-read EMPTY follower/following rows, skipped the decrement,
|
|
1723
|
-
// and left every migrated local follower's followingCount permanently +1 over.
|
|
1724
|
-
// Batching makes delete+decrement all-or-nothing: a crash before commit changes
|
|
1725
|
-
// nothing (retry re-runs cleanly from the still-present old edges); a crash
|
|
1726
|
-
// after commit re-reads no old edges (the batch is then a no-op) → the
|
|
1727
|
-
// decrement is applied exactly once.
|
|
1728
|
-
const moveOps = [];
|
|
1729
|
-
if (followerRewrites.length > 0) {
|
|
1730
|
-
moveOps.push(db.insert(follows).values(followerRewrites));
|
|
1731
|
-
}
|
|
1732
|
-
if (followerRows.length > 0) {
|
|
1733
|
-
moveOps.push(
|
|
1734
|
-
db.delete(follows).where(eq(follows.followerApId, oldActorApId)),
|
|
1735
|
-
);
|
|
1736
|
-
}
|
|
1737
|
-
if (followingRewrites.length > 0) {
|
|
1738
|
-
moveOps.push(db.insert(follows).values(followingRewrites));
|
|
1739
|
-
}
|
|
1740
|
-
if (followingRows.length > 0) {
|
|
1741
|
-
moveOps.push(
|
|
1742
|
-
db.delete(follows).where(eq(follows.followingApId, oldActorApId)),
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
for (const followerApId of localAcceptedFollowerApIds) {
|
|
1746
|
-
moveOps.push(
|
|
1747
|
-
db
|
|
1748
|
-
.update(actors)
|
|
1749
|
-
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
1750
|
-
.where(
|
|
1751
|
-
and(eq(actors.apId, followerApId), gt(actors.followingCount, 0)),
|
|
1752
|
-
),
|
|
1753
|
-
);
|
|
1754
|
-
}
|
|
1755
|
-
for (const followeeApId of droppedAcceptedLocalFolloweeApIds) {
|
|
1756
|
-
moveOps.push(
|
|
1757
|
-
db
|
|
1758
|
-
.update(actors)
|
|
1759
|
-
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
1760
|
-
.where(and(eq(actors.apId, followeeApId), gt(actors.followerCount, 0))),
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
if (moveOps.length > 0) {
|
|
1764
|
-
await runBatch(db, moveOps as unknown as Parameters<typeof runBatch>[1]);
|
|
1765
|
-
}
|
|
2098
|
+
await rewriteMovedFollowGraph({
|
|
2099
|
+
db,
|
|
2100
|
+
oldActorApId,
|
|
2101
|
+
newActorApId,
|
|
2102
|
+
refollowPrefix,
|
|
2103
|
+
});
|
|
1766
2104
|
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
type: "Follow",
|
|
1777
|
-
actor: followerApId,
|
|
1778
|
-
object: newActorApId,
|
|
1779
|
-
};
|
|
1780
|
-
try {
|
|
1781
|
-
await db.insert(activities).values({
|
|
1782
|
-
apId: followId,
|
|
1783
|
-
type: "Follow",
|
|
1784
|
-
actorApId: followerApId,
|
|
1785
|
-
objectApId: newActorApId,
|
|
1786
|
-
rawJson: JSON.stringify(followActivity),
|
|
1787
|
-
direction: "outbound",
|
|
1788
|
-
});
|
|
1789
|
-
await enqueueDeliveryToActor(c.env, followId, newActorApId);
|
|
1790
|
-
} catch (e) {
|
|
1791
|
-
log.warn("Failed to issue migration re-follow to move target", {
|
|
1792
|
-
event: "ap.move.refollow_failed",
|
|
1793
|
-
follower: followerApId,
|
|
1794
|
-
newActor: newActorApId,
|
|
1795
|
-
error: e,
|
|
1796
|
-
});
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
2105
|
+
// Queue delivery is the only non-transactional step. Do not swallow a real
|
|
2106
|
+
// producer failure: the inbox claim remains uncommitted and a retry resumes
|
|
2107
|
+
// the durable activities through the prefix check at the top of this handler.
|
|
2108
|
+
await enqueuePersistedMoveRefollows({
|
|
2109
|
+
db,
|
|
2110
|
+
env: c.env,
|
|
2111
|
+
newActorApId,
|
|
2112
|
+
refollowPrefix,
|
|
2113
|
+
});
|
|
1799
2114
|
}
|
|
1800
2115
|
|
|
1801
2116
|
// ---------------------------------------------------------------------------
|