@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
|
@@ -14,14 +14,12 @@ import {
|
|
|
14
14
|
or,
|
|
15
15
|
sql,
|
|
16
16
|
} from "drizzle-orm";
|
|
17
|
-
import type { BatchItem } from "drizzle-orm/batch";
|
|
18
17
|
import {
|
|
19
18
|
activities,
|
|
20
19
|
actorCache,
|
|
21
20
|
actors,
|
|
22
21
|
blocks,
|
|
23
22
|
communities,
|
|
24
|
-
deliveryQueue,
|
|
25
23
|
follows,
|
|
26
24
|
inbox,
|
|
27
25
|
mediaUploads,
|
|
@@ -29,6 +27,8 @@ import {
|
|
|
29
27
|
notDeleted,
|
|
30
28
|
nowIso,
|
|
31
29
|
objects,
|
|
30
|
+
runBatch,
|
|
31
|
+
type D1Statement,
|
|
32
32
|
} from "../../db/index.ts";
|
|
33
33
|
import type { Database } from "../../db/index.ts";
|
|
34
34
|
import type { Env, Variables } from "../types.ts";
|
|
@@ -36,21 +36,33 @@ import {
|
|
|
36
36
|
activityApId,
|
|
37
37
|
formatUsername,
|
|
38
38
|
generateId,
|
|
39
|
+
isLocal,
|
|
39
40
|
isSafeRemoteUrl,
|
|
40
41
|
parseLimit,
|
|
41
42
|
parseOffset,
|
|
42
43
|
safeJsonParse,
|
|
43
44
|
} from "../federation-helpers.ts";
|
|
44
45
|
import { enqueueFanoutToFollowers } from "../lib/delivery/queue.ts";
|
|
46
|
+
import { prepareDeliveryFanoutJob } from "../lib/delivery/fanout-outbox.ts";
|
|
45
47
|
import {
|
|
46
48
|
destinationDeclaresAlias,
|
|
47
49
|
resolveMoveTarget,
|
|
48
50
|
} from "../lib/account-migration.ts";
|
|
49
51
|
import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
+
import { blockActorAndSeverFollowPair } from "../lib/follow-edge-mutations.ts";
|
|
53
|
+
import {
|
|
54
|
+
deletePersonalActorBlock,
|
|
55
|
+
deletePersonalActorMute,
|
|
56
|
+
personalActorIsBlockedBy,
|
|
57
|
+
resolveRetainedPersonalMuteTarget,
|
|
58
|
+
} from "../lib/personal-actor-moderation.ts";
|
|
59
|
+
import {
|
|
60
|
+
finalizeActorDeletionAndSessions,
|
|
61
|
+
teardownActor,
|
|
62
|
+
} from "./account-teardown.ts";
|
|
52
63
|
import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
|
|
53
64
|
import {
|
|
65
|
+
type AppContext,
|
|
54
66
|
actorExists,
|
|
55
67
|
createRelation,
|
|
56
68
|
deleteRelation,
|
|
@@ -69,12 +81,56 @@ import {
|
|
|
69
81
|
} from "./actors-helpers.ts";
|
|
70
82
|
import { reapReplacedMediaUrl } from "./posts/delete-cascade.ts";
|
|
71
83
|
import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
|
|
84
|
+
import { isActorBlockedStrict } from "../lib/blocklist.ts";
|
|
72
85
|
import { encodeFeedCursor, feedCursorWhere } from "../lib/feed-cursor.ts";
|
|
73
86
|
import { chunkForInClause } from "../lib/chunk.ts";
|
|
74
87
|
import { logger } from "../lib/logger.ts";
|
|
88
|
+
import { excludeModeratedActors } from "../lib/feed-exclude.ts";
|
|
89
|
+
import {
|
|
90
|
+
activityDeleteCascadeStatements,
|
|
91
|
+
activityDeliveryDrainedGuard,
|
|
92
|
+
deleteActivitiesCascade,
|
|
93
|
+
} from "../lib/activity-delete-cascade.ts";
|
|
94
|
+
import {
|
|
95
|
+
claimRemoteActorFetch,
|
|
96
|
+
fetchAndUpsertActorCache,
|
|
97
|
+
getRemoteActorFetchFailure,
|
|
98
|
+
recordRemoteActorFetchFailure,
|
|
99
|
+
type RemoteActorFetchFailureState,
|
|
100
|
+
} from "../lib/activitypub-actor-cache.ts";
|
|
75
101
|
|
|
76
102
|
const log = logger.child({ component: "actors" });
|
|
77
103
|
|
|
104
|
+
function remoteActorFetchFailureResponse(
|
|
105
|
+
c: AppContext,
|
|
106
|
+
failure: RemoteActorFetchFailureState,
|
|
107
|
+
): Response {
|
|
108
|
+
if (failure.kind === "gone") {
|
|
109
|
+
return c.json({ error: "Remote actor is gone", code: "ACTOR_GONE" }, 410);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const retryAfter = failure.retryAfterSeconds ?? 30;
|
|
113
|
+
c.header("Retry-After", retryAfter.toString());
|
|
114
|
+
if (failure.kind === "invalid") {
|
|
115
|
+
return c.json(
|
|
116
|
+
{
|
|
117
|
+
error: "Remote actor returned invalid data",
|
|
118
|
+
code: "ACTOR_INVALID",
|
|
119
|
+
retry_after: retryAfter,
|
|
120
|
+
},
|
|
121
|
+
502,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return c.json(
|
|
125
|
+
{
|
|
126
|
+
error: "Remote actor is temporarily unavailable",
|
|
127
|
+
code: "ACTOR_UNAVAILABLE",
|
|
128
|
+
retry_after: retryAfter,
|
|
129
|
+
},
|
|
130
|
+
503,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
78
134
|
// Mastodon-parity profile metadata limits. Mastodon caps profile fields at 4
|
|
79
135
|
// rows with bounded name/value lengths; we mirror that to keep the served
|
|
80
136
|
// actor document and federated Update(Person) bounded.
|
|
@@ -137,15 +193,21 @@ function fieldsToAttachments(
|
|
|
137
193
|
const TOMBSTONE_REAP_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
138
194
|
|
|
139
195
|
/**
|
|
140
|
-
*
|
|
196
|
+
* Reap signing material for tombstoned local actors and communities whose
|
|
197
|
+
* federation work has drained.
|
|
141
198
|
*
|
|
142
199
|
* A tombstone is only reaped when (a) its `deletedAt` is older than
|
|
143
|
-
* TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* rows are removed alongside the actor so they
|
|
200
|
+
* TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal endpoint-delivery or
|
|
201
|
+
* actor-resolution rows for any of its Delete activities — i.e. nothing can
|
|
202
|
+
* still resolve into a delivery that needs the private key to sign a retry.
|
|
203
|
+
* The preserved Delete activity rows are removed alongside the actor so they
|
|
204
|
+
* do not accumulate forever.
|
|
147
205
|
*
|
|
148
|
-
*
|
|
206
|
+
* Actor rows are hard-deleted as before. Community rows remain as permanent
|
|
207
|
+
* lifecycle tombstones so their scoped objects keep failing the read gate, but
|
|
208
|
+
* their private signing key is scrubbed after every Group activity projection
|
|
209
|
+
* is terminal. Returns the total number of actor rows deleted + community keys
|
|
210
|
+
* scrubbed.
|
|
149
211
|
*/
|
|
150
212
|
export async function reapDrainedTombstones(db: Database): Promise<number> {
|
|
151
213
|
const cutoff = new Date(Date.now() - TOMBSTONE_REAP_AFTER_MS).toISOString();
|
|
@@ -157,70 +219,83 @@ export async function reapDrainedTombstones(db: Database): Promise<number> {
|
|
|
157
219
|
and(sql`${actors.deletedAt} IS NOT NULL`, lt(actors.deletedAt, cutoff)),
|
|
158
220
|
)
|
|
159
221
|
.limit(100);
|
|
160
|
-
if (candidates.length === 0) return 0;
|
|
161
222
|
|
|
162
223
|
let reaped = 0;
|
|
163
224
|
for (const { apId } of candidates) {
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"processing",
|
|
197
|
-
"failed",
|
|
198
|
-
"retry_wait",
|
|
199
|
-
]),
|
|
200
|
-
),
|
|
201
|
-
)
|
|
202
|
-
.limit(1)
|
|
203
|
-
.get();
|
|
204
|
-
if (pending) {
|
|
205
|
-
hasPendingDelivery = true;
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (hasPendingDelivery) continue;
|
|
210
|
-
}
|
|
225
|
+
// Check the delivery authority on every statement in ONE batch. A
|
|
226
|
+
// resolution worker can materialize an endpoint while cleanup is starting;
|
|
227
|
+
// an unguarded cascade would delete that new job and then the signing key.
|
|
228
|
+
// D1 serializes this batch, so any active row that appears before or during
|
|
229
|
+
// it fences all subsequent cleanup statements.
|
|
230
|
+
const deleteActivityWhere = and(
|
|
231
|
+
eq(activities.actorApId, apId),
|
|
232
|
+
eq(activities.type, "Delete"),
|
|
233
|
+
)!;
|
|
234
|
+
const drainedGuard = activityDeliveryDrainedGuard(db, deleteActivityWhere);
|
|
235
|
+
await runBatch(db, [
|
|
236
|
+
...activityDeleteCascadeStatements(db, deleteActivityWhere, {
|
|
237
|
+
guard: drainedGuard,
|
|
238
|
+
}),
|
|
239
|
+
db
|
|
240
|
+
.delete(actors)
|
|
241
|
+
.where(
|
|
242
|
+
and(
|
|
243
|
+
eq(actors.apId, apId),
|
|
244
|
+
isNotNull(actors.deletedAt),
|
|
245
|
+
lt(actors.deletedAt, cutoff),
|
|
246
|
+
drainedGuard,
|
|
247
|
+
),
|
|
248
|
+
) as D1Statement,
|
|
249
|
+
]);
|
|
250
|
+
const actorRemains = await db
|
|
251
|
+
.select({ apId: actors.apId })
|
|
252
|
+
.from(actors)
|
|
253
|
+
.where(eq(actors.apId, apId))
|
|
254
|
+
.get();
|
|
255
|
+
if (!actorRemains) reaped += 1;
|
|
256
|
+
}
|
|
211
257
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
258
|
+
// A retired Group cannot be hard-deleted: its community row is the durable
|
|
259
|
+
// negative lifecycle fact used by direct object reads. Once all Group-authored
|
|
260
|
+
// activity projections have drained, remove those ledgers/projections and
|
|
261
|
+
// scrub only the private signing key.
|
|
262
|
+
const communityCandidates = await db
|
|
263
|
+
.select({ apId: communities.apId })
|
|
264
|
+
.from(communities)
|
|
265
|
+
.where(
|
|
266
|
+
and(
|
|
267
|
+
isNotNull(communities.deletedAt),
|
|
268
|
+
lt(communities.deletedAt, cutoff),
|
|
269
|
+
ne(communities.privateKeyPem, ""),
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
.limit(100);
|
|
273
|
+
for (const { apId } of communityCandidates) {
|
|
274
|
+
const groupActivityWhere = eq(activities.actorApId, apId);
|
|
275
|
+
const drainedGuard = activityDeliveryDrainedGuard(db, groupActivityWhere);
|
|
276
|
+
await runBatch(db, [
|
|
277
|
+
...activityDeleteCascadeStatements(db, groupActivityWhere, {
|
|
278
|
+
guard: drainedGuard,
|
|
279
|
+
}),
|
|
280
|
+
db
|
|
281
|
+
.update(communities)
|
|
282
|
+
.set({ privateKeyPem: "" })
|
|
283
|
+
.where(
|
|
284
|
+
and(
|
|
285
|
+
eq(communities.apId, apId),
|
|
286
|
+
isNotNull(communities.deletedAt),
|
|
287
|
+
lt(communities.deletedAt, cutoff),
|
|
288
|
+
ne(communities.privateKeyPem, ""),
|
|
289
|
+
drainedGuard,
|
|
290
|
+
),
|
|
291
|
+
) as D1Statement,
|
|
292
|
+
]);
|
|
293
|
+
const communityAfter = await db
|
|
294
|
+
.select({ privateKeyPem: communities.privateKeyPem })
|
|
295
|
+
.from(communities)
|
|
296
|
+
.where(eq(communities.apId, apId))
|
|
297
|
+
.get();
|
|
298
|
+
if (communityAfter?.privateKeyPem === "") reaped += 1;
|
|
224
299
|
}
|
|
225
300
|
|
|
226
301
|
return reaped;
|
|
@@ -239,23 +314,10 @@ export async function reapDrainedTombstones(db: Database): Promise<number> {
|
|
|
239
314
|
* delivery_queue rows AND the preserved Delete activity rows, so re-registration
|
|
240
315
|
* starts clean and no half-signed Delete is sent.
|
|
241
316
|
*
|
|
242
|
-
* Mirrors the activity/
|
|
243
|
-
* unconditional
|
|
244
|
-
* terminal delivery rows; terminal rows are removed alongside the activity.
|
|
317
|
+
* Mirrors the activity/projection cleanup `reapDrainedTombstones` performs,
|
|
318
|
+
* but is unconditional because the revive supersedes the Delete.
|
|
245
319
|
* Returns the number of Delete activities cancelled.
|
|
246
320
|
*/
|
|
247
|
-
// D1 has no interactive transactions, but both the D1 and libsql drivers expose
|
|
248
|
-
// `db.batch([...])`, which commits a list of prepared statements atomically. The
|
|
249
|
-
// shared `Database` union aliases the abstract `BaseSQLiteDatabase` base (which
|
|
250
|
-
// does not surface `batch`), so we narrow to the concrete batch surface here
|
|
251
|
-
// rather than weakening the shared type (mirrors inbox-interaction-handlers.ts).
|
|
252
|
-
type BatchStatement = BatchItem<"sqlite">;
|
|
253
|
-
interface BatchableDb {
|
|
254
|
-
batch(
|
|
255
|
-
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
256
|
-
): Promise<unknown>;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
321
|
export async function cancelTombstoneDelete(
|
|
260
322
|
db: Database,
|
|
261
323
|
apId: string,
|
|
@@ -267,20 +329,13 @@ export async function cancelTombstoneDelete(
|
|
|
267
329
|
const deleteActivityIds = deleteActivities.map((a) => a.apId);
|
|
268
330
|
if (deleteActivityIds.length === 0) return 0;
|
|
269
331
|
|
|
270
|
-
// Drop every
|
|
271
|
-
// Delete is superseded, including in-flight retry_wait jobs) together
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// Delete activities, which would blow D1's 100-bound-param cap and 500 the
|
|
276
|
-
// revive.
|
|
332
|
+
// Drop every durable projection for those Delete activities (any queue status
|
|
333
|
+
// — the Delete is superseded, including in-flight retry_wait jobs) together
|
|
334
|
+
// with the activity rows, so no signer can pick up work referencing an
|
|
335
|
+
// activity whose actor row has been rotated. Each chunk stays in one atomic
|
|
336
|
+
// batch; chunking keeps the predicate below D1's 100-bound-param ceiling.
|
|
277
337
|
for (const chunk of chunkForInClause(deleteActivityIds)) {
|
|
278
|
-
await (db
|
|
279
|
-
db
|
|
280
|
-
.delete(deliveryQueue)
|
|
281
|
-
.where(inArray(deliveryQueue.activityApId, chunk)),
|
|
282
|
-
db.delete(activities).where(inArray(activities.apId, chunk)),
|
|
283
|
-
]);
|
|
338
|
+
await deleteActivitiesCascade(db, inArray(activities.apId, chunk));
|
|
284
339
|
}
|
|
285
340
|
|
|
286
341
|
return deleteActivityIds.length;
|
|
@@ -359,7 +414,7 @@ actorsRoute.get(
|
|
|
359
414
|
following_count: a.followingCount,
|
|
360
415
|
post_count: a.postCount,
|
|
361
416
|
created_at: a.createdAt,
|
|
362
|
-
username: formatUsername(a.apId),
|
|
417
|
+
username: formatUsername(a.apId, a.preferredUsername),
|
|
363
418
|
})),
|
|
364
419
|
});
|
|
365
420
|
},
|
|
@@ -394,20 +449,11 @@ actorsRoute.post("/me/blocked", async (c) => {
|
|
|
394
449
|
c,
|
|
395
450
|
"block",
|
|
396
451
|
async (db, actorId, targetId) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// handleBlock). Without this a blocked actor who was an accepted follower
|
|
403
|
-
// stays in the fan-out set and keeps receiving the blocker's posts, and
|
|
404
|
-
// both actors' follower/following counts stay inflated — defeating the
|
|
405
|
-
// whole point of the block. severFollowEdge gates each decrement on an
|
|
406
|
-
// EXISTS(... status='accepted') subquery so a pending/absent edge is a
|
|
407
|
-
// clean no-op (no under-count). Pending follow-request rows are deleted by
|
|
408
|
-
// the edge delete inside severFollowEdge regardless of status.
|
|
409
|
-
await severFollowEdge(db, targetId, actorId); // target follows actor
|
|
410
|
-
await severFollowEdge(db, actorId, targetId); // actor follows target
|
|
452
|
+
// The block row, both follow-edge removals, and all four counter
|
|
453
|
+
// reconciliations are one D1 commit. Without that boundary a failure
|
|
454
|
+
// between the two directions can leave follower-only delivery authority
|
|
455
|
+
// half alive even though the API reports failure.
|
|
456
|
+
await blockActorAndSeverFollowPair(db, actorId, targetId);
|
|
411
457
|
},
|
|
412
458
|
async (db, actorId) =>
|
|
413
459
|
(
|
|
@@ -417,17 +463,14 @@ actorsRoute.post("/me/blocked", async (c) => {
|
|
|
417
463
|
.where(eq(blocks.blockerApId, actorId))
|
|
418
464
|
.get()
|
|
419
465
|
)?.n ?? 0,
|
|
466
|
+
(db, actorId, targetId) => personalActorIsBlockedBy(db, actorId, targetId),
|
|
420
467
|
);
|
|
421
468
|
});
|
|
422
469
|
|
|
423
470
|
// Unblock a user
|
|
424
471
|
actorsRoute.delete("/me/blocked", async (c) => {
|
|
425
472
|
return deleteRelation(c, "block", (db, actorId, targetId) =>
|
|
426
|
-
db
|
|
427
|
-
.delete(blocks)
|
|
428
|
-
.where(
|
|
429
|
-
and(eq(blocks.blockerApId, actorId), eq(blocks.blockedApId, targetId)),
|
|
430
|
-
),
|
|
473
|
+
deletePersonalActorBlock(db, actorId, targetId),
|
|
431
474
|
);
|
|
432
475
|
});
|
|
433
476
|
|
|
@@ -458,11 +501,20 @@ actorsRoute.post("/me/muted", async (c) => {
|
|
|
458
501
|
return createRelation(
|
|
459
502
|
c,
|
|
460
503
|
"mute",
|
|
461
|
-
(db, actorId, targetId) =>
|
|
462
|
-
|
|
504
|
+
async (db, actorId, targetId) => {
|
|
505
|
+
const retainedTarget = await resolveRetainedPersonalMuteTarget(
|
|
506
|
+
db,
|
|
507
|
+
actorId,
|
|
508
|
+
targetId,
|
|
509
|
+
);
|
|
510
|
+
await db
|
|
463
511
|
.insert(mutes)
|
|
464
|
-
.values({
|
|
465
|
-
|
|
512
|
+
.values({
|
|
513
|
+
muterApId: actorId,
|
|
514
|
+
mutedApId: retainedTarget ?? targetId,
|
|
515
|
+
})
|
|
516
|
+
.onConflictDoNothing();
|
|
517
|
+
},
|
|
466
518
|
async (db, actorId) =>
|
|
467
519
|
(
|
|
468
520
|
await db
|
|
@@ -471,15 +523,15 @@ actorsRoute.post("/me/muted", async (c) => {
|
|
|
471
523
|
.where(eq(mutes.muterApId, actorId))
|
|
472
524
|
.get()
|
|
473
525
|
)?.n ?? 0,
|
|
526
|
+
async (db, actorId, targetId) =>
|
|
527
|
+
Boolean(await resolveRetainedPersonalMuteTarget(db, actorId, targetId)),
|
|
474
528
|
);
|
|
475
529
|
});
|
|
476
530
|
|
|
477
531
|
// Unmute a user
|
|
478
532
|
actorsRoute.delete("/me/muted", async (c) => {
|
|
479
533
|
return deleteRelation(c, "mute", (db, actorId, targetId) =>
|
|
480
|
-
db
|
|
481
|
-
.delete(mutes)
|
|
482
|
-
.where(and(eq(mutes.muterApId, actorId), eq(mutes.mutedApId, targetId))),
|
|
534
|
+
deletePersonalActorMute(db, actorId, targetId),
|
|
483
535
|
);
|
|
484
536
|
});
|
|
485
537
|
|
|
@@ -509,11 +561,17 @@ actorsRoute.post("/me/delete", async (c) => {
|
|
|
509
561
|
// Delete(Actor) (snapshotting follower inboxes before the graph is dropped),
|
|
510
562
|
// reconciles every counterparty's denormalized counters, deletes the actor's
|
|
511
563
|
// edges / interactions / memberships / media / DM state, hands off sole-owned
|
|
512
|
-
// communities to an heir, hard-deletes its authored objects
|
|
513
|
-
//
|
|
514
|
-
// applies to each sub-account below — one
|
|
515
|
-
// hand-rolled copy that had to be kept in
|
|
516
|
-
|
|
564
|
+
// communities to an heir, and hard-deletes its authored objects. The owner
|
|
565
|
+
// tombstone is finalized below after all sub-accounts succeed. This is the
|
|
566
|
+
// EXACT cascade teardownActor applies to each sub-account below — one
|
|
567
|
+
// chokepoint instead of a ~440-line hand-rolled copy that had to be kept in
|
|
568
|
+
// sync.
|
|
569
|
+
// Defer the owner's tombstone until every sub-account has completed. If a
|
|
570
|
+
// later media/DB step fails, the owner row and session remain live so the
|
|
571
|
+
// same authenticated request can retry the incomplete cascade.
|
|
572
|
+
await teardownActor(db, c.env, baseUrl, actorApIdVal, actor.followers_url, {
|
|
573
|
+
finalizeActor: false,
|
|
574
|
+
});
|
|
517
575
|
// Now fully tear down each sub-account (same cascade the owner just received)
|
|
518
576
|
// so no "deleted" sub-account content / edge / counter / membership / sole
|
|
519
577
|
// ownership survives, and each federates its own Delete(Actor).
|
|
@@ -521,6 +579,12 @@ actorsRoute.post("/me/delete", async (c) => {
|
|
|
521
579
|
await teardownActor(db, c.env, baseUrl, sub.apId, sub.followersUrl);
|
|
522
580
|
}
|
|
523
581
|
|
|
582
|
+
// Finalize the owner only after all owner/sub-account teardown work has
|
|
583
|
+
// succeeded. Sessions are removed last so a failure above cannot discard
|
|
584
|
+
// the retry authority. Tombstoned actors fail closed in session-actor.ts.
|
|
585
|
+
const sessionActorIds = [actorApIdVal, ...subAccounts.map((s) => s.apId)];
|
|
586
|
+
await finalizeActorDeletionAndSessions(db, actorApIdVal, sessionActorIds);
|
|
587
|
+
|
|
524
588
|
deleteCookie(c, "session");
|
|
525
589
|
|
|
526
590
|
return c.json({ success: true });
|
|
@@ -577,6 +641,8 @@ actorsRoute.get("/:identifier/posts", async (c) => {
|
|
|
577
641
|
conditions.push(eq(objects.visibility, "public"));
|
|
578
642
|
conditions.push(eq(objects.audienceJson, "[]"));
|
|
579
643
|
}
|
|
644
|
+
const excludeAuthor = excludeModeratedActors(currentActor?.ap_id ?? "");
|
|
645
|
+
if (excludeAuthor) conditions.push(excludeAuthor);
|
|
580
646
|
// Composite (published, apId) cursor so posts sharing a published millisecond
|
|
581
647
|
// aren't skipped at a page boundary (see lib/feed-cursor.ts).
|
|
582
648
|
const profileCursor = feedCursorWhere(
|
|
@@ -634,7 +700,7 @@ actorsRoute.get("/:identifier/posts", async (c) => {
|
|
|
634
700
|
type: p.type,
|
|
635
701
|
author: {
|
|
636
702
|
ap_id: p.attributedTo,
|
|
637
|
-
username: formatUsername(p.attributedTo),
|
|
703
|
+
username: formatUsername(p.attributedTo, author?.preferredUsername),
|
|
638
704
|
preferred_username: author?.preferredUsername || null,
|
|
639
705
|
name: author?.name || null,
|
|
640
706
|
icon_url: author?.iconUrl || null,
|
|
@@ -702,12 +768,82 @@ actorsRoute.get("/:identifier", async (c) => {
|
|
|
702
768
|
.get();
|
|
703
769
|
|
|
704
770
|
if (!localActor) {
|
|
705
|
-
|
|
771
|
+
// A cached remote actor remains in storage so an operator unblock can be
|
|
772
|
+
// reversible, but it must not stay directly discoverable while its actor
|
|
773
|
+
// or domain is defederated. Apply the same complete actor/domain decision
|
|
774
|
+
// as ingress and outbound delivery before projecting the cached profile.
|
|
775
|
+
if (await isActorBlockedStrict(db, apId)) {
|
|
776
|
+
return c.json({ error: "Actor not found" }, 404);
|
|
777
|
+
}
|
|
778
|
+
let cachedActor = await db
|
|
706
779
|
.select()
|
|
707
780
|
.from(actorCache)
|
|
708
781
|
.where(eq(actorCache.apId, apId))
|
|
709
782
|
.get();
|
|
710
|
-
if (!cachedActor)
|
|
783
|
+
if (!cachedActor) {
|
|
784
|
+
// A Follow edge can outlive the optional presentation cache (for
|
|
785
|
+
// example after cache eviction or an interrupted refresh). The profile
|
|
786
|
+
// link projected from that durable relationship must remain recoverable,
|
|
787
|
+
// but this public read route must not become an arbitrary remote-fetch
|
|
788
|
+
// proxy. Only an authenticated read of a safe non-local actor with a
|
|
789
|
+
// retained pending/accepted relationship may hydrate the missing row.
|
|
790
|
+
const retainedRelationship =
|
|
791
|
+
currentActor && !isLocal(apId, baseUrl) && isSafeRemoteUrl(apId)
|
|
792
|
+
? await db
|
|
793
|
+
.select({ followerApId: follows.followerApId })
|
|
794
|
+
.from(follows)
|
|
795
|
+
.where(
|
|
796
|
+
and(
|
|
797
|
+
or(
|
|
798
|
+
eq(follows.followerApId, apId),
|
|
799
|
+
eq(follows.followingApId, apId),
|
|
800
|
+
),
|
|
801
|
+
inArray(follows.status, ["pending", "accepted"]),
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
.get()
|
|
805
|
+
: null;
|
|
806
|
+
if (!retainedRelationship) {
|
|
807
|
+
return c.json({ error: "Actor not found" }, 404);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const activeFailure = await getRemoteActorFetchFailure(db, apId);
|
|
811
|
+
if (activeFailure) {
|
|
812
|
+
return remoteActorFetchFailureResponse(c, activeFailure);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const fetchClaim = await claimRemoteActorFetch(db, apId);
|
|
816
|
+
if (!fetchClaim.owned) {
|
|
817
|
+
return remoteActorFetchFailureResponse(c, fetchClaim.failure);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const recovered = await fetchAndUpsertActorCache(db, apId, {
|
|
821
|
+
mode: "insert",
|
|
822
|
+
signer: await getInstanceFetchSigner(c),
|
|
823
|
+
});
|
|
824
|
+
if (!recovered.ok) {
|
|
825
|
+
const failure = await recordRemoteActorFetchFailure(
|
|
826
|
+
db,
|
|
827
|
+
apId,
|
|
828
|
+
recovered,
|
|
829
|
+
new Date(),
|
|
830
|
+
fetchClaim.token,
|
|
831
|
+
);
|
|
832
|
+
// Another entry path may have successfully cached the actor while this
|
|
833
|
+
// claimed network request was failing. The failure update is fenced by
|
|
834
|
+
// actor_cache existence; re-read before returning a stale 410/503.
|
|
835
|
+
cachedActor = await db
|
|
836
|
+
.select()
|
|
837
|
+
.from(actorCache)
|
|
838
|
+
.where(eq(actorCache.apId, apId))
|
|
839
|
+
.get();
|
|
840
|
+
if (!cachedActor) {
|
|
841
|
+
return remoteActorFetchFailureResponse(c, failure);
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
cachedActor = recovered.row;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
711
847
|
|
|
712
848
|
// Project the remote actor's AS Person document (cached `rawJson`) so the
|
|
713
849
|
// client banner/fields work for REMOTE actors too: `attachment` ->
|
|
@@ -727,7 +863,10 @@ actorsRoute.get("/:identifier", async (c) => {
|
|
|
727
863
|
name: cachedActor.name,
|
|
728
864
|
summary: cachedActor.summary,
|
|
729
865
|
icon_url: cachedActor.iconUrl,
|
|
730
|
-
username: formatUsername(
|
|
866
|
+
username: formatUsername(
|
|
867
|
+
cachedActor.apId,
|
|
868
|
+
cachedActor.preferredUsername,
|
|
869
|
+
),
|
|
731
870
|
fields: sanitizeProfileFields(rawAttachment),
|
|
732
871
|
also_known_as: rawAlsoKnownAs.slice(0, MAX_ALSO_KNOWN_AS),
|
|
733
872
|
moved_to: rawMovedTo,
|
|
@@ -784,7 +923,7 @@ actorsRoute.get("/:identifier", async (c) => {
|
|
|
784
923
|
post_count: localActor.postCount,
|
|
785
924
|
is_private: localActor.isPrivate,
|
|
786
925
|
created_at: localActor.createdAt,
|
|
787
|
-
username: formatUsername(localActor.apId),
|
|
926
|
+
username: formatUsername(localActor.apId, localActor.preferredUsername),
|
|
788
927
|
fields: sanitizeProfileFields(safeJsonParse(localActor.fieldsJson, [])),
|
|
789
928
|
also_known_as: safeJsonParse<string[]>(localActor.alsoKnownAsJson, []),
|
|
790
929
|
moved_to: localActor.movedTo,
|
|
@@ -907,23 +1046,11 @@ actorsRoute.put("/me", async (c) => {
|
|
|
907
1046
|
}
|
|
908
1047
|
|
|
909
1048
|
const db = c.get("db");
|
|
910
|
-
await db.update(actors).set(updates).where(eq(actors.apId, actor.ap_id));
|
|
911
|
-
|
|
912
|
-
// A replaced avatar/header is attached to no object, so no GC path reclaims
|
|
913
|
-
// the prior blob — reap it now if the old URL is a local /media upload no
|
|
914
|
-
// longer referenced anywhere (best-effort; never fails the update).
|
|
915
|
-
for (const [dbKey, oldUrl] of [
|
|
916
|
-
["iconUrl", actor.icon_url],
|
|
917
|
-
["headerUrl", actor.header_url],
|
|
918
|
-
] as const) {
|
|
919
|
-
if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
|
|
920
|
-
await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
1049
|
|
|
924
1050
|
// The `actor` context snapshot predates the new columns, so to federate a
|
|
925
|
-
// faithful Person we read the
|
|
926
|
-
// not itself supply them.
|
|
1051
|
+
// faithful Person we read the currently persisted values when the request
|
|
1052
|
+
// does not itself supply them. This read deliberately precedes the atomic
|
|
1053
|
+
// profile + Activity + fanout-intent write below.
|
|
927
1054
|
if (nextFields === undefined || nextAlsoKnownAs === undefined) {
|
|
928
1055
|
const persisted = await db
|
|
929
1056
|
.select({
|
|
@@ -1032,18 +1159,46 @@ actorsRoute.put("/me", async (c) => {
|
|
|
1032
1159
|
object: personObject,
|
|
1033
1160
|
};
|
|
1034
1161
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1162
|
+
const preparedFanout = await prepareDeliveryFanoutJob(db, {
|
|
1163
|
+
kind: "followers",
|
|
1164
|
+
activityId: updateActivityId,
|
|
1165
|
+
targetApId: actor.ap_id,
|
|
1166
|
+
});
|
|
1167
|
+
await runBatch(db, [
|
|
1168
|
+
db
|
|
1169
|
+
.update(actors)
|
|
1170
|
+
.set(updates)
|
|
1171
|
+
.where(eq(actors.apId, actor.ap_id)) as D1Statement,
|
|
1172
|
+
db.insert(activities).values({
|
|
1037
1173
|
apId: updateActivityId,
|
|
1038
1174
|
type: "Update",
|
|
1039
1175
|
actorApId: actor.ap_id,
|
|
1040
1176
|
objectApId: actor.ap_id,
|
|
1041
1177
|
rawJson: JSON.stringify(updateActivity),
|
|
1042
1178
|
direction: "outbound",
|
|
1043
|
-
})
|
|
1179
|
+
}) as D1Statement,
|
|
1180
|
+
preparedFanout.statement,
|
|
1181
|
+
]);
|
|
1182
|
+
|
|
1183
|
+
// A replaced avatar/header is attached to no object, so no GC path reclaims
|
|
1184
|
+
// the prior blob — reap it after the durable profile mutation commits if the
|
|
1185
|
+
// old URL is a local /media upload no longer referenced anywhere
|
|
1186
|
+
// (best-effort; never fails the update).
|
|
1187
|
+
for (const [dbKey, oldUrl] of [
|
|
1188
|
+
["iconUrl", actor.icon_url],
|
|
1189
|
+
["headerUrl", actor.header_url],
|
|
1190
|
+
] as const) {
|
|
1191
|
+
if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
|
|
1192
|
+
await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1044
1197
|
await enqueueFanoutToFollowers(c.env, updateActivityId, actor.ap_id);
|
|
1045
1198
|
} catch (err) {
|
|
1046
|
-
//
|
|
1199
|
+
// The semantic fanout is already durable in the same commit as the profile
|
|
1200
|
+
// and Activity. Queue publication is only a best-effort wakeup; a later
|
|
1201
|
+
// request/startup/retention sweep republishes the pending row.
|
|
1047
1202
|
log.error("Failed to enqueue profile Update federation", {
|
|
1048
1203
|
event: "actors.profile.update_federation_failed",
|
|
1049
1204
|
actor: actor.ap_id,
|
|
@@ -1110,11 +1265,6 @@ actorsRoute.post("/me/move", async (c) => {
|
|
|
1110
1265
|
const db = c.get("db");
|
|
1111
1266
|
const baseUrl = c.env.APP_URL;
|
|
1112
1267
|
|
|
1113
|
-
await db
|
|
1114
|
-
.update(actors)
|
|
1115
|
-
.set({ movedTo: target })
|
|
1116
|
-
.where(eq(actors.apId, actor.ap_id));
|
|
1117
|
-
|
|
1118
1268
|
// Federate Move(actor) addressed to followers so they migrate their follow.
|
|
1119
1269
|
const moveActivityId = activityApId(baseUrl, generateId());
|
|
1120
1270
|
const moveActivity = {
|
|
@@ -1128,18 +1278,32 @@ actorsRoute.post("/me/move", async (c) => {
|
|
|
1128
1278
|
cc: [actor.followers_url],
|
|
1129
1279
|
};
|
|
1130
1280
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1281
|
+
const preparedFanout = await prepareDeliveryFanoutJob(db, {
|
|
1282
|
+
kind: "followers",
|
|
1283
|
+
activityId: moveActivityId,
|
|
1284
|
+
targetApId: actor.ap_id,
|
|
1285
|
+
});
|
|
1286
|
+
await runBatch(db, [
|
|
1287
|
+
db
|
|
1288
|
+
.update(actors)
|
|
1289
|
+
.set({ movedTo: target })
|
|
1290
|
+
.where(eq(actors.apId, actor.ap_id)) as D1Statement,
|
|
1291
|
+
db.insert(activities).values({
|
|
1133
1292
|
apId: moveActivityId,
|
|
1134
1293
|
type: "Move",
|
|
1135
1294
|
actorApId: actor.ap_id,
|
|
1136
1295
|
objectApId: actor.ap_id,
|
|
1137
1296
|
rawJson: JSON.stringify(moveActivity),
|
|
1138
1297
|
direction: "outbound",
|
|
1139
|
-
})
|
|
1298
|
+
}) as D1Statement,
|
|
1299
|
+
preparedFanout.statement,
|
|
1300
|
+
]);
|
|
1301
|
+
|
|
1302
|
+
try {
|
|
1140
1303
|
await enqueueFanoutToFollowers(c.env, moveActivityId, actor.ap_id);
|
|
1141
1304
|
} catch (err) {
|
|
1142
|
-
//
|
|
1305
|
+
// The moved_to marker, Activity, and semantic fanout are already one atomic
|
|
1306
|
+
// durable commit. Queue publication is only a best-effort wakeup.
|
|
1143
1307
|
log.error("Failed to enqueue account Move federation", {
|
|
1144
1308
|
event: "actors.account.move_federation_failed",
|
|
1145
1309
|
actor: actor.ap_id,
|
|
@@ -1241,7 +1405,7 @@ actorsRoute.get("/me/export", async (c) => {
|
|
|
1241
1405
|
fields: sanitizeProfileFields(safeJsonParse(profileRow.fieldsJson, [])),
|
|
1242
1406
|
also_known_as: safeJsonParse<string[]>(profileRow.alsoKnownAsJson, []),
|
|
1243
1407
|
moved_to: profileRow.movedTo,
|
|
1244
|
-
username: formatUsername(profileRow.apId),
|
|
1408
|
+
username: formatUsername(profileRow.apId, profileRow.preferredUsername),
|
|
1245
1409
|
},
|
|
1246
1410
|
outbox: {
|
|
1247
1411
|
type: "OrderedCollection",
|