@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,482 @@
1
+ import { and, count, eq, inArray, lt, sql } from "drizzle-orm";
2
+ import type { Database } from "../../../db/index.ts";
3
+ import {
4
+ actorCache,
5
+ actors,
6
+ blocks,
7
+ communityMembers,
8
+ follows,
9
+ mutes,
10
+ objects,
11
+ storyVotes,
12
+ } from "../../../db/index.ts";
13
+ import type { IObjectStorage } from "../../runtime/types.ts";
14
+ import { objectApId, safeJsonParse } from "../../federation-helpers.ts";
15
+ import {
16
+ deleteObjectCascade,
17
+ purgeMediaBlobs,
18
+ } from "../posts/delete-cascade.ts";
19
+ import { chunkForInClause } from "../../lib/chunk.ts";
20
+
21
+ // Bound the per-run expired-story reap. The cleanup is opportunistic (fired,
22
+ // unawaited, from the hot story-read path), so a hostile followed host that
23
+ // accumulated thousands of now-expired stories must not make a single run load
24
+ // and sequentially cascade the whole backlog. Each run drains up to this many;
25
+ // the rest are reaped on subsequent runs.
26
+ const STORY_CLEANUP_BATCH = 200;
27
+
28
+ interface VoteResults {
29
+ [optionIndex: number]: number;
30
+ }
31
+
32
+ type OverlayPosition = {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ };
38
+
39
+ type Overlay = {
40
+ type: string;
41
+ position: OverlayPosition;
42
+ oneOf?: unknown[];
43
+ href?: string;
44
+ };
45
+
46
+ type StoryAttachment = {
47
+ type: string;
48
+ mediaType: string;
49
+ url: string;
50
+ r2_key: string;
51
+ width: number;
52
+ height: number;
53
+ };
54
+
55
+ type StoredStoryData = {
56
+ attachment?: {
57
+ r2_key?: string;
58
+ content_type?: string;
59
+ url?: string;
60
+ width?: number;
61
+ height?: number;
62
+ };
63
+ displayDuration?: string;
64
+ caption?: string;
65
+ overlays?: Overlay[];
66
+ };
67
+
68
+ type ActorCacheEntry = {
69
+ preferredUsername: string | null;
70
+ name: string | null;
71
+ iconUrl: string | null;
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Story lookup helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** Resolve a story param (short ID or full URL) to a full ap_id. */
79
+ export function resolveStoryApId(storyId: string, baseUrl: string): string {
80
+ return storyId.startsWith("http") ? storyId : objectApId(baseUrl, storyId);
81
+ }
82
+
83
+ /** Find a single Story object by ap_id. Returns null/undefined when not found. */
84
+ export function findStory(db: Database, apId: string) {
85
+ return db
86
+ .select()
87
+ .from(objects)
88
+ .where(and(eq(objects.apId, apId), eq(objects.type, "Story")))
89
+ .get();
90
+ }
91
+
92
+ /**
93
+ * Whether `viewerApId` may read `story` — and therefore interact with it. A
94
+ * story's reach mirrors the story read endpoints: the author always; a
95
+ * community story → an accepted member of that community; a personal story → an
96
+ * accepted follower of the author. Gates view / vote / like / share so a
97
+ * non-follower / non-member cannot interact with (notify the author about, or
98
+ * re-share) a story they were never shown. Anonymous (`null`) never passes.
99
+ * Fails closed.
100
+ */
101
+ export async function canViewerReadStory(
102
+ db: Database,
103
+ story: { attributedTo: string; communityApId: string | null },
104
+ viewerApId: string | null | undefined,
105
+ ): Promise<boolean> {
106
+ if (!viewerApId) return false;
107
+ if (story.attributedTo === viewerApId) return true;
108
+ if (story.communityApId) {
109
+ const member = await db
110
+ .select({ actorApId: communityMembers.actorApId })
111
+ .from(communityMembers)
112
+ .where(
113
+ and(
114
+ eq(communityMembers.communityApId, story.communityApId),
115
+ eq(communityMembers.actorApId, viewerApId),
116
+ ),
117
+ )
118
+ .get();
119
+ return Boolean(member);
120
+ }
121
+ const follow = await db
122
+ .select({ followerApId: follows.followerApId })
123
+ .from(follows)
124
+ .where(
125
+ and(
126
+ eq(follows.followerApId, viewerApId),
127
+ eq(follows.followingApId, story.attributedTo),
128
+ eq(follows.status, "accepted"),
129
+ ),
130
+ )
131
+ .get();
132
+ return Boolean(follow);
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Blocked / muted helpers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /** Fetch blocked and muted ap_ids for the given actor. */
140
+ export async function fetchBlockedAndMutedIds(
141
+ db: Database,
142
+ actorApId: string,
143
+ ): Promise<{ blockedIds: string[]; mutedIds: string[] }> {
144
+ const [blockRows, muteRows] = await Promise.all([
145
+ db
146
+ .select({ blockedApId: blocks.blockedApId })
147
+ .from(blocks)
148
+ .where(eq(blocks.blockerApId, actorApId)),
149
+ db
150
+ .select({ mutedApId: mutes.mutedApId })
151
+ .from(mutes)
152
+ .where(eq(mutes.muterApId, actorApId)),
153
+ ]);
154
+
155
+ return {
156
+ blockedIds: blockRows.map((b) => b.blockedApId),
157
+ mutedIds: muteRows.map((m) => m.mutedApId),
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Vote helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Get vote counts for a single story, keyed by option index. */
166
+ export async function getVoteCounts(
167
+ db: Database,
168
+ storyApId: string,
169
+ ): Promise<VoteResults> {
170
+ const votes = await db
171
+ .select({
172
+ optionIndex: storyVotes.optionIndex,
173
+ count: count(),
174
+ })
175
+ .from(storyVotes)
176
+ .where(eq(storyVotes.storyApId, storyApId))
177
+ .groupBy(storyVotes.optionIndex);
178
+
179
+ return Object.fromEntries(votes.map((v) => [v.optionIndex, v.count]));
180
+ }
181
+
182
+ /** Sum all vote counts in a VoteResults record. */
183
+ export function sumVotes(votes: VoteResults): number {
184
+ return Object.values(votes).reduce((sum, count) => sum + count, 0);
185
+ }
186
+
187
+ /**
188
+ * Batch-fetch vote counts and (optionally) the current user's votes
189
+ * for a list of story ap_ids.
190
+ */
191
+ export async function fetchBatchVotes(
192
+ db: Database,
193
+ storyApIds: string[],
194
+ actorApId?: string,
195
+ ): Promise<{
196
+ allVotes: Record<string, VoteResults>;
197
+ userVotes: Record<string, number>;
198
+ }> {
199
+ if (storyApIds.length === 0) {
200
+ return { allVotes: {}, userVotes: {} };
201
+ }
202
+
203
+ const voteCounts = await db
204
+ .select({
205
+ storyApId: storyVotes.storyApId,
206
+ optionIndex: storyVotes.optionIndex,
207
+ count: count(),
208
+ })
209
+ .from(storyVotes)
210
+ .where(inArray(storyVotes.storyApId, storyApIds))
211
+ .groupBy(storyVotes.storyApId, storyVotes.optionIndex);
212
+
213
+ const allVotes: Record<string, VoteResults> = {};
214
+ for (const v of voteCounts) {
215
+ if (!allVotes[v.storyApId]) allVotes[v.storyApId] = {};
216
+ allVotes[v.storyApId][v.optionIndex] = v.count;
217
+ }
218
+
219
+ let userVotes: Record<string, number> = {};
220
+ if (actorApId) {
221
+ const rows = await db
222
+ .select({
223
+ storyApId: storyVotes.storyApId,
224
+ optionIndex: storyVotes.optionIndex,
225
+ })
226
+ .from(storyVotes)
227
+ .where(
228
+ and(
229
+ inArray(storyVotes.storyApId, storyApIds),
230
+ eq(storyVotes.actorApId, actorApId),
231
+ ),
232
+ );
233
+ userVotes = Object.fromEntries(
234
+ rows.map((r) => [r.storyApId, r.optionIndex]),
235
+ );
236
+ }
237
+
238
+ return { allVotes, userVotes };
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Actor cache helper
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /** Fetch cached actor info for remote authors missing a local actor row. */
246
+ export async function fetchActorCache(
247
+ db: Database,
248
+ remoteApIds: string[],
249
+ ): Promise<Record<string, ActorCacheEntry>> {
250
+ if (remoteApIds.length === 0) return {};
251
+
252
+ const cached = await db
253
+ .select({
254
+ apId: actorCache.apId,
255
+ preferredUsername: actorCache.preferredUsername,
256
+ name: actorCache.name,
257
+ iconUrl: actorCache.iconUrl,
258
+ })
259
+ .from(actorCache)
260
+ .where(inArray(actorCache.apId, remoteApIds));
261
+
262
+ return Object.fromEntries(
263
+ cached.map((a) => [
264
+ a.apId,
265
+ {
266
+ preferredUsername: a.preferredUsername,
267
+ name: a.name,
268
+ iconUrl: a.iconUrl,
269
+ },
270
+ ]),
271
+ );
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Story data cleanup & transformation
276
+ // ---------------------------------------------------------------------------
277
+
278
+ export async function cleanupExpiredStories(
279
+ db: Database,
280
+ media?: IObjectStorage,
281
+ ): Promise<number> {
282
+ const now = new Date().toISOString();
283
+
284
+ const expiredStories = await db
285
+ .select({ apId: objects.apId, attributedTo: objects.attributedTo })
286
+ .from(objects)
287
+ .where(and(eq(objects.type, "Story"), lt(objects.endTime, now)))
288
+ .limit(STORY_CLEANUP_BATCH);
289
+
290
+ if (expiredStories.length === 0) return 0;
291
+
292
+ const expiredApIds = expiredStories.map((s) => s.apId);
293
+
294
+ // Reap every child edge for each expired story through the shared object
295
+ // cascade (likes / announces / bookmarks / object_recipients / story_views /
296
+ // story_votes / story_shares + attached media_uploads), so expiry can't
297
+ // orphan rows the hand-rolled list previously missed (announces, bookmarks,
298
+ // media). The helper reads each object row, so run it before deleting them.
299
+ // When a `media` binding is threaded in, the backing R2 blobs are best-effort
300
+ // purged too so expired-story storage does not leak.
301
+ const mediaKeys: string[] = [];
302
+ for (const apId of expiredApIds) {
303
+ mediaKeys.push(...(await deleteObjectCascade(db, apId, media)));
304
+ }
305
+
306
+ // Delete EXACTLY the cascaded set (chunked for D1's 100-bound-param cap), not a
307
+ // broad `endTime < now` — with the per-run limit a broad delete could remove a
308
+ // story whose children weren't reaped this run, orphaning them. Capture the
309
+ // rows THIS sweep actually removed via RETURNING so the postCount decrement
310
+ // below is keyed on the real delete, not the earlier SELECT.
311
+ const deletedAuthors: (string | null)[] = [];
312
+ for (const chunk of chunkForInClause(expiredApIds)) {
313
+ const removed = await db
314
+ .delete(objects)
315
+ .where(inArray(objects.apId, chunk))
316
+ .returning({ attributedTo: objects.attributedTo });
317
+ for (const row of removed) deletedAuthors.push(row.attributedTo);
318
+ }
319
+
320
+ // Mirror story CREATION's postCount +1 (routes.ts) at expiry — the DOMINANT
321
+ // end-of-life path. Without this, every story a user posts permanently inflates
322
+ // their profile post_count by 1 once it expires (create bumps, explicit delete
323
+ // decrements, but expiry forgot to). Decrement each LOCAL author by the number
324
+ // of THEIR stories THIS sweep actually DELETED (from RETURNING), not the
325
+ // pre-delete SELECT: D1 serializes writes, so when concurrent isolates race the
326
+ // same expiry batch only the winning DELETE returns a given row and a losing
327
+ // isolate's DELETE matches 0 rows — so each story is counted exactly once and
328
+ // postCount never double-decrements. (The per-isolate in-flight boolean in
329
+ // routes.ts cannot serialize across Workers isolates.) MAX(0, …) guards
330
+ // underflow; remote authors have no `actors` row so the update is a no-op.
331
+ const perAuthor = new Map<string, number>();
332
+ for (const author of deletedAuthors) {
333
+ if (author) perAuthor.set(author, (perAuthor.get(author) ?? 0) + 1);
334
+ }
335
+ for (const [author, n] of perAuthor) {
336
+ await db
337
+ .update(actors)
338
+ .set({ postCount: sql`MAX(0, ${actors.postCount} - ${n})` })
339
+ .where(eq(actors.apId, author));
340
+ }
341
+
342
+ // Irreversible R2 purge LAST — after the objects rows are gone.
343
+ await purgeMediaBlobs(media, mediaKeys);
344
+
345
+ return expiredApIds.length;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Overlay validation
350
+ // ---------------------------------------------------------------------------
351
+
352
+ const POSITION_FIELDS = ["x", "y", "width", "height"] as const;
353
+
354
+ function isOverlayRecord(value: unknown): value is Record<string, unknown> {
355
+ return typeof value === "object" && value !== null && !Array.isArray(value);
356
+ }
357
+
358
+ // Bound the overlays payload (count + serialized size). The caption is
359
+ // length-capped at the caller; without this an overlays blob could carry up to
360
+ // the global 1 MiB body cap into the stored + federated story.
361
+ const MAX_OVERLAYS = 20;
362
+ const MAX_OVERLAYS_JSON_LENGTH = 16 * 1024;
363
+
364
+ export function validateOverlays(overlays: unknown[]): {
365
+ valid: boolean;
366
+ error?: string;
367
+ } {
368
+ if (!Array.isArray(overlays)) {
369
+ return { valid: false, error: "overlays must be an array" };
370
+ }
371
+ if (overlays.length > MAX_OVERLAYS) {
372
+ return { valid: false, error: `Too many overlays (max ${MAX_OVERLAYS})` };
373
+ }
374
+ if (JSON.stringify(overlays).length > MAX_OVERLAYS_JSON_LENGTH) {
375
+ return { valid: false, error: "overlays payload too large" };
376
+ }
377
+
378
+ let questionCount = 0;
379
+ for (const [i, raw] of overlays.entries()) {
380
+ if (!isOverlayRecord(raw)) {
381
+ return { valid: false, error: `overlay[${i}] must be an object` };
382
+ }
383
+ const overlay = raw;
384
+
385
+ if (typeof overlay.type !== "string" || overlay.type === "") {
386
+ return { valid: false, error: `overlay[${i}].type is required` };
387
+ }
388
+
389
+ if (!isOverlayRecord(overlay.position)) {
390
+ return { valid: false, error: `overlay[${i}].position is required` };
391
+ }
392
+
393
+ const position = overlay.position;
394
+ for (const field of POSITION_FIELDS) {
395
+ const val = position[field];
396
+ if (typeof val !== "number" || val < 0 || val > 1) {
397
+ return {
398
+ valid: false,
399
+ error: `overlay[${i}].position.${field} must be 0.0-1.0`,
400
+ };
401
+ }
402
+ }
403
+
404
+ if (overlay.type === "Question") {
405
+ // At most ONE Question (poll) per story: votes are keyed only by
406
+ // (storyApId, actorApId) and the tally aggregates purely by optionIndex
407
+ // with no question dimension, so a second Question would conflate tallies
408
+ // and be unvoteable. Reject multi-poll stories at creation.
409
+ questionCount += 1;
410
+ if (questionCount > 1) {
411
+ return {
412
+ valid: false,
413
+ error: "A story may have at most one Question (poll) overlay",
414
+ };
415
+ }
416
+ const oneOf = overlay.oneOf;
417
+ if (!Array.isArray(oneOf) || oneOf.length < 2 || oneOf.length > 4) {
418
+ return {
419
+ valid: false,
420
+ error: `overlay[${i}].oneOf must have 2-4 options`,
421
+ };
422
+ }
423
+ }
424
+
425
+ if (overlay.type === "Link") {
426
+ if (typeof overlay.href !== "string" || overlay.href === "") {
427
+ return { valid: false, error: `overlay[${i}].href is required` };
428
+ }
429
+ try {
430
+ new URL(overlay.href);
431
+ } catch {
432
+ return { valid: false, error: `overlay[${i}].href is invalid URL` };
433
+ }
434
+ }
435
+ }
436
+
437
+ return { valid: true };
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Story data transformation
442
+ // ---------------------------------------------------------------------------
443
+
444
+ export function transformStoryData(attachmentsJson: string): {
445
+ attachment: StoryAttachment;
446
+ caption?: string;
447
+ displayDuration: string;
448
+ overlays?: Overlay[];
449
+ } {
450
+ const stored = safeJsonParse<StoredStoryData>(attachmentsJson, {});
451
+ const r2Key = stored.attachment?.r2_key;
452
+ const contentType = stored.attachment?.content_type || "image/jpeg";
453
+ const externalUrl = stored.attachment?.url;
454
+
455
+ let url = "";
456
+ if (r2Key) {
457
+ // Strip the known leading `uploads/` prefix explicitly. `String.replace`
458
+ // with a string argument only removes the first, anywhere-matching
459
+ // occurrence, which would mangle keys whose remainder contains the token.
460
+ const UPLOADS_PREFIX = "uploads/";
461
+ const publicPath = r2Key.startsWith(UPLOADS_PREFIX)
462
+ ? r2Key.slice(UPLOADS_PREFIX.length)
463
+ : r2Key;
464
+ url = `/media/${publicPath}`;
465
+ } else if (externalUrl) {
466
+ url = externalUrl;
467
+ }
468
+
469
+ return {
470
+ attachment: {
471
+ type: contentType.startsWith("video/") ? "Video" : "Document",
472
+ mediaType: contentType,
473
+ url,
474
+ r2_key: r2Key || "",
475
+ width: stored.attachment?.width || 1080,
476
+ height: stored.attachment?.height || 1920,
477
+ },
478
+ caption: stored.caption || undefined,
479
+ displayDuration: stored.displayDuration || "PT5S",
480
+ overlays: stored.overlays || undefined,
481
+ };
482
+ }