@takosjp/yurucommu-core 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Shared object-delete cascade.
3
+ *
4
+ * Migrations declare `ON DELETE CASCADE` on the object's interaction/edge
5
+ * tables (likes, announces, bookmarks, object_recipients, story_views,
6
+ * story_votes, story_shares), but SQLite enforces foreign keys only when
7
+ * `PRAGMA foreign_keys = ON` is set on the connection — which is NOT reliably
8
+ * the case on every runtime/connection (D1 does not honour it, and the libsql
9
+ * connection is not guaranteed to have it). Deleting an object row therefore
10
+ * orphans those child rows on at least some runtimes.
11
+ *
12
+ * This helper deletes every child row keyed by `objectApId` deterministically,
13
+ * independent of FK enforcement, so the data stays consistent on all runtimes.
14
+ * It does NOT delete the `objects` row itself — callers do that (and own any
15
+ * counter/fanout side effects) — and it intentionally leaves `activities`
16
+ * alone, whose `object_ap_id` is `ON DELETE SET NULL`, not CASCADE.
17
+ *
18
+ * Used by BOTH the local post-delete path (routes.ts `DELETE /posts/:id`) and
19
+ * the remote `handleDelete` inbox path so neither can orphan rows.
20
+ */
21
+
22
+ import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
23
+ import type { Database } from "../../../db/index.ts";
24
+ import type { IObjectStorage } from "../../runtime/types.ts";
25
+ import {
26
+ activities,
27
+ actors,
28
+ announces,
29
+ bookmarks,
30
+ communities,
31
+ inbox as inboxTable,
32
+ likes,
33
+ mediaUploads,
34
+ objectRecipients,
35
+ objects,
36
+ storyShares,
37
+ storyViews,
38
+ storyVotes,
39
+ } from "../../../db/index.ts";
40
+
41
+ /**
42
+ * Reap the `media_uploads` rows attached to a single object.
43
+ *
44
+ * `media_uploads` has no FK column to `objects` — the link is the same one the
45
+ * media auth path uses (`routes/media.ts`): an object references its uploads by
46
+ * embedding the media URL / `r2_key` in `attachments_json`, and each upload is
47
+ * the unique, indexed `r2_key` (`media_uploads_r2_key_idx`) owned by the
48
+ * object's author (`uploader_ap_id`, `media_uploads_uploader_idx`). So we scan
49
+ * the author's uploads (indexed equality) and delete the ones whose `r2_key` is
50
+ * substring-referenced in this object's `attachments_json`, mirroring the
51
+ * `attachmentMatches` semantics. There is no engine-level CASCADE for this edge,
52
+ * so without this the upload rows orphan on every runtime.
53
+ *
54
+ * Returns silently when the object row is already gone (caller may delete the
55
+ * object before or after calling this) or has no attachments.
56
+ *
57
+ * When a `media` object-store binding is provided, the backing R2 blobs for the
58
+ * reaped uploads are best-effort deleted by `r2_key` (mirroring the
59
+ * account-delete teardown in `routes/actors.ts`). R2 errors never fail the DB
60
+ * delete; without this the blobs leak forever (there is no orphaned-key GC).
61
+ *
62
+ * A blob is only purged when its `r2_key` is no longer referenced by any OTHER
63
+ * still-present object of the same author (an `r2_key`/media URL can be embedded
64
+ * in more than one object's `attachments_json` even though the `media_uploads`
65
+ * row is unique). Deleting the blob while another object still shows it would
66
+ * data-loss the shared media, so the R2 delete is gated on the reference count
67
+ * dropping to zero. The DB-row delete is unconditional (the reaped rows belong
68
+ * to this object's reap set regardless).
69
+ */
70
+ async function deleteAttachedMediaUploads(
71
+ db: Database,
72
+ objectApId: string,
73
+ media?: IObjectStorage,
74
+ ): Promise<string[]> {
75
+ const obj = await db
76
+ .select({
77
+ attributedTo: objects.attributedTo,
78
+ attachmentsJson: objects.attachmentsJson,
79
+ })
80
+ .from(objects)
81
+ .where(eq(objects.apId, objectApId))
82
+ .get();
83
+
84
+ // No object row (already deleted) or no attachment payload: nothing to reap.
85
+ if (!obj || !obj.attachmentsJson || obj.attachmentsJson === "[]") return [];
86
+
87
+ const attachmentsJson = obj.attachmentsJson;
88
+
89
+ // An attachment may reference an upload by EITHER its `r2_key`
90
+ // (`uploads/<id>.<ext>`) OR its served `/media/<id>.<ext>` URL — the auth-path
91
+ // matcher (media.ts attachmentMatches) accepts both, but the GC historically
92
+ // matched only `r2_key`. A stored attachment carrying only the `/media/` URL
93
+ // (any client that omits `r2_key`) therefore slipped the reap and leaked its
94
+ // blob forever. Match BOTH forms here so the GC is symmetric with the auth path.
95
+ const mediaUrlForKey = (r2Key: string): string =>
96
+ r2Key.startsWith("uploads/")
97
+ ? `/media/${r2Key.slice("uploads/".length)}`
98
+ : r2Key;
99
+
100
+ // Indexed scan over the author's own uploads, then substring-match the upload
101
+ // identity (r2_key OR /media URL) against the object's attachment payload.
102
+ const candidates = await db
103
+ .select({ id: mediaUploads.id, r2Key: mediaUploads.r2Key })
104
+ .from(mediaUploads)
105
+ .where(eq(mediaUploads.uploaderApId, obj.attributedTo));
106
+
107
+ const orphaned = candidates.filter(
108
+ (m) =>
109
+ attachmentsJson.includes(m.r2Key) ||
110
+ attachmentsJson.includes(mediaUrlForKey(m.r2Key)),
111
+ );
112
+
113
+ if (orphaned.length === 0) return [];
114
+
115
+ // Before any R2 purge, find which of these `r2_key`s are still referenced by
116
+ // ANOTHER (different `ap_id`), still-present object of the same author. Such
117
+ // a key must NOT have its blob deleted — another object still shows it. We
118
+ // run this read while the rows are still present so an OTHER object whose
119
+ // `attachments_json` happens to point back at this object's reap set is
120
+ // honoured; the lookup is scoped to the author's own objects (indexed) and
121
+ // excludes the object being reaped.
122
+ const stillReferencedKeys = new Set<string>();
123
+ if (media) {
124
+ // For each orphaned key, ask SQL (indexed by attributedTo) whether ANOTHER
125
+ // present object of this author still references it — instead of loading
126
+ // EVERY other object's attachmentsJson into memory and substring-scanning
127
+ // them all (O(objects × keys), unbounded for a prolific author). `orphaned`
128
+ // is only this object's own attachments (a handful), so this is a small,
129
+ // bounded set of indexed lookups. Uses instr() (literal substring), NOT
130
+ // `LIKE '%<key>%'`: a 73-char `uploads/<64-hex>.png` key in a `%...%` pattern
131
+ // exceeds D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500, ~50 chars).
132
+ // Mirrors media.ts findReferencingObject.
133
+ for (const m of orphaned) {
134
+ const ref = await db
135
+ .select({ apId: objects.apId })
136
+ .from(objects)
137
+ .where(
138
+ and(
139
+ eq(objects.attributedTo, obj.attributedTo),
140
+ ne(objects.apId, objectApId),
141
+ or(
142
+ sql`instr(${objects.attachmentsJson}, ${m.r2Key}) > 0`,
143
+ sql`instr(${objects.attachmentsJson}, ${mediaUrlForKey(m.r2Key)}) > 0`,
144
+ ),
145
+ ),
146
+ )
147
+ .get();
148
+ if (ref) stillReferencedKeys.add(m.r2Key);
149
+ }
150
+ }
151
+
152
+ // Delete the media_uploads rows whose blob we are actually purging. Rows
153
+ // whose `r2_key` is still referenced by another present object are KEPT (row
154
+ // AND blob) so that, when that final referencer is later deleted, this same
155
+ // candidates scan still finds the row and can GC the now-orphaned blob —
156
+ // otherwise the shared blob would leak permanently once its DB row vanished.
157
+ // Without a `media` binding there is no R2 to GC, so all rows are removed.
158
+ const idsToDelete = media
159
+ ? orphaned.filter((m) => !stillReferencedKeys.has(m.r2Key)).map((m) => m.id)
160
+ : orphaned.map((m) => m.id);
161
+ if (idsToDelete.length > 0) {
162
+ await db.delete(mediaUploads).where(inArray(mediaUploads.id, idsToDelete));
163
+ }
164
+
165
+ // Return the keys whose reference count has now dropped to zero. The caller
166
+ // purges them via purgeMediaBlobs AFTER it deletes the objects row, so the
167
+ // IRREVERSIBLE R2 delete is the trailing step: if the objects-row delete fails
168
+ // the blob is still present and the post is recoverable, rather than the post
169
+ // surviving with a permanently-deleted blob (a broken image with no recovery).
170
+ // Keys still embedded in another present object's `attachments_json` are kept
171
+ // (blob + media_uploads row) so shared media isn't lost.
172
+ return media
173
+ ? orphaned.map((m) => m.r2Key).filter((k) => !stillReferencedKeys.has(k))
174
+ : [];
175
+ }
176
+
177
+ /**
178
+ * Best-effort purge of unreferenced R2 blobs, intended as the TRAILING step
179
+ * after the objects row has been deleted (see deleteObjectCascade's return).
180
+ * R2 errors never propagate — a failed purge degrades to a leaked blob, the
181
+ * system's already-accepted media failure mode.
182
+ */
183
+ export async function purgeMediaBlobs(
184
+ media: IObjectStorage | undefined,
185
+ keys: string[],
186
+ ): Promise<void> {
187
+ if (!media || keys.length === 0) return;
188
+ try {
189
+ await media.delete(keys);
190
+ } catch {
191
+ // Swallow: storage purge is best-effort and must not fail the delete flow.
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Reap a profile / community image blob that was just REPLACED.
197
+ *
198
+ * Avatar / header / community-icon media is attached to no object, so neither
199
+ * the object-delete GC nor the expired-story reap ever touches it. Replacing the
200
+ * image — a normal, repeatable user action — would otherwise orphan the prior
201
+ * blob + `media_uploads` row in R2 forever (there is no orphaned-key sweep).
202
+ *
203
+ * Call this AFTER the new URL is persisted: it reaps the OLD `/media/...` URL's
204
+ * upload iff that URL is no longer referenced by ANY actor avatar/header, any
205
+ * non-deleted community icon, or any of the uploader's objects' attachments
206
+ * (URL or `r2_key` form). No-op for empty/external URLs or an upload owned by a
207
+ * different actor. Best-effort: never throws into the caller's response path.
208
+ */
209
+ export async function reapReplacedMediaUrl(
210
+ db: Database,
211
+ oldUrl: string | null | undefined,
212
+ uploaderApId: string,
213
+ media?: IObjectStorage,
214
+ ): Promise<void> {
215
+ try {
216
+ if (!oldUrl || !oldUrl.startsWith("/media/")) return;
217
+ const filename = oldUrl.slice("/media/".length);
218
+ if (!filename || filename.includes("/") || filename.includes("..")) return;
219
+ const r2Key = `uploads/${filename}`;
220
+
221
+ // Only ever reap a blob THIS actor uploaded.
222
+ const owned = await db
223
+ .select({ id: mediaUploads.id })
224
+ .from(mediaUploads)
225
+ .where(
226
+ and(
227
+ eq(mediaUploads.r2Key, r2Key),
228
+ eq(mediaUploads.uploaderApId, uploaderApId),
229
+ ),
230
+ )
231
+ .get();
232
+ if (!owned) return;
233
+
234
+ // Still an actor avatar/header somewhere (e.g. set as both icon and header)?
235
+ const actorRef = await db
236
+ .select({ apId: actors.apId })
237
+ .from(actors)
238
+ .where(or(eq(actors.iconUrl, oldUrl), eq(actors.headerUrl, oldUrl)))
239
+ .get();
240
+ if (actorRef) return;
241
+
242
+ // Still a (non-deleted) community icon?
243
+ const communityRef = await db
244
+ .select({ apId: communities.apId })
245
+ .from(communities)
246
+ .where(
247
+ and(eq(communities.iconUrl, oldUrl), isNull(communities.deletedAt)),
248
+ )
249
+ .get();
250
+ if (communityRef) return;
251
+
252
+ // Still embedded in one of the uploader's objects' attachments (URL or key)?
253
+ const objectRef = await db
254
+ .select({ apId: objects.apId })
255
+ .from(objects)
256
+ .where(
257
+ and(
258
+ eq(objects.attributedTo, uploaderApId),
259
+ or(
260
+ sql`instr(${objects.attachmentsJson}, ${oldUrl}) > 0`,
261
+ sql`instr(${objects.attachmentsJson}, ${r2Key}) > 0`,
262
+ ),
263
+ ),
264
+ )
265
+ .get();
266
+ if (objectRef) return;
267
+
268
+ // Unreferenced: drop the DB row, then best-effort purge the blob.
269
+ await db.delete(mediaUploads).where(eq(mediaUploads.id, owned.id));
270
+ await purgeMediaBlobs(media, [r2Key]);
271
+ } catch {
272
+ // Best-effort hygiene: a failure just leaves the prior blob (the existing
273
+ // accepted media failure mode), never breaks the profile/community update.
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Delete all child rows that reference `objectApId` (the object's `ap_id`).
279
+ *
280
+ * Mirrors the `ON DELETE CASCADE` edges declared in the migrations:
281
+ * likes, announces, bookmarks, object_recipients,
282
+ * story_views, story_votes, story_shares.
283
+ *
284
+ * Also reaps the object-attached `media_uploads` rows, which have no FK to
285
+ * `objects` and would otherwise orphan (see `deleteAttachedMediaUploads`). When
286
+ * a `media` binding is passed, the backing R2 blobs are best-effort deleted too
287
+ * so storage does not leak; pass `c.env.MEDIA` from the request context.
288
+ *
289
+ * Does not touch the `objects` row or `activities` (SET NULL, not CASCADE).
290
+ */
291
+ export async function deleteObjectCascade(
292
+ db: Database,
293
+ objectApId: string,
294
+ media?: IObjectStorage,
295
+ ): Promise<string[]> {
296
+ // Reap the media_uploads rows + child rows while the object row (and its
297
+ // attachments_json) is still readable. Returns the R2 keys whose blobs are now
298
+ // unreferenced — the caller MUST purge them via purgeMediaBlobs AFTER it has
299
+ // deleted the objects row, so the irreversible R2 delete is the trailing step.
300
+ const mediaKeys = await deleteAttachedMediaUploads(db, objectApId, media);
301
+ await db.delete(likes).where(eq(likes.objectApId, objectApId));
302
+ await db.delete(announces).where(eq(announces.objectApId, objectApId));
303
+ await db.delete(bookmarks).where(eq(bookmarks.objectApId, objectApId));
304
+ await db
305
+ .delete(objectRecipients)
306
+ .where(eq(objectRecipients.objectApId, objectApId));
307
+ await db.delete(storyViews).where(eq(storyViews.storyApId, objectApId));
308
+ await db.delete(storyVotes).where(eq(storyVotes.storyApId, objectApId));
309
+ await db.delete(storyShares).where(eq(storyShares.storyApId, objectApId));
310
+
311
+ // Reap NOTIFICATION inbox rows that pointed at this object (a Like / Announce /
312
+ // reply-Create that notified a local user). After the object row is gone the
313
+ // notifications query's leftJoin(objects) yields NULL, so these would otherwise
314
+ // render as dangling notifications (blank content, dead link) AND keep inflating
315
+ // the unread badge (the inbox row stays read=0). Delete the INBOX rows only, via
316
+ // a subquery (D1-param-safe) — NOT the `activities` rows, which may include the
317
+ // outbound federation Delete that must survive to be delivered.
318
+ await db
319
+ .delete(inboxTable)
320
+ .where(
321
+ inArray(
322
+ inboxTable.activityApId,
323
+ db
324
+ .select({ id: activities.apId })
325
+ .from(activities)
326
+ .where(eq(activities.objectApId, objectApId)),
327
+ ),
328
+ );
329
+ return mediaKeys;
330
+ }