@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,906 @@
1
+ // Story routes for Yurucommu backend
2
+ // v2: 1 Story = 1 Media (Instagram style)
3
+ import { Hono } from "hono";
4
+ import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
5
+ import type { Database } from "../../../db/index.ts";
6
+ import {
7
+ activities,
8
+ actors,
9
+ communityMembers,
10
+ follows,
11
+ likes,
12
+ objects,
13
+ storyViews,
14
+ } from "../../../db/index.ts";
15
+ import {
16
+ deleteObjectCascade,
17
+ purgeMediaBlobs,
18
+ } from "../posts/delete-cascade.ts";
19
+ import type { Env, Variables } from "../../types.ts";
20
+ import type { IObjectStorage } from "../../runtime/types.ts";
21
+ import {
22
+ activityApId,
23
+ actorApId,
24
+ formatUsername,
25
+ generateId,
26
+ objectApId,
27
+ } from "../../federation-helpers.ts";
28
+ import { storyToActivityPub } from "../../lib/activitypub-helpers.ts";
29
+ import { excludeBlockedMutedAuthors } from "../../lib/feed-exclude.ts";
30
+ import { maybeReapDrainedTombstones } from "../actors.ts";
31
+ import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
32
+ import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
33
+ import {
34
+ cleanupExpiredStories,
35
+ fetchActorCache,
36
+ fetchBatchVotes,
37
+ fetchBlockedAndMutedIds,
38
+ sumVotes,
39
+ transformStoryData,
40
+ validateOverlays,
41
+ } from "./query-helpers.ts";
42
+ import {
43
+ enqueueFanoutToCommunity,
44
+ enqueueFanoutToFollowers,
45
+ } from "../../lib/delivery/queue.ts";
46
+ import { logger } from "../../lib/logger.ts";
47
+
48
+ const log = logger.child({ component: "stories.routes" });
49
+
50
+ /**
51
+ * Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
52
+ * The shared `Database` union type does not surface `batch` (it lives on the
53
+ * concrete subclasses), so we reach it through a structural cast at the call
54
+ * site that needs an atomic multi-statement write.
55
+ */
56
+ type Batchable = {
57
+ batch(statements: readonly unknown[]): Promise<unknown>;
58
+ };
59
+
60
+ const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
61
+
62
+ // Best-effort, opportunistic retention of expired stories.
63
+ //
64
+ // This is NOT a substitute for a scheduled job: this Worker has no `scheduled`
65
+ // handler / cron trigger, so expiry cleanup is triggered probabilistically on
66
+ // the read path. Expired stories are already excluded from every read query
67
+ // (the feed/single-story handlers filter on `endTime`), so the only impact of
68
+ // a missed sweep is storage growth, not stale data leaking to users. The guard
69
+ // below ensures at most one sweep runs at a time per isolate, so a burst of
70
+ // feed requests cannot kick off several concurrent full-table delete sweeps.
71
+ let expiredStoryCleanupInFlight = false;
72
+
73
+ function maybeCleanupExpiredStories(
74
+ db: Database,
75
+ media?: IObjectStorage,
76
+ ): void {
77
+ if (expiredStoryCleanupInFlight) return;
78
+ if (Math.random() >= 0.01) return; // ~1% of feed requests per isolate
79
+
80
+ expiredStoryCleanupInFlight = true;
81
+ // Pass the MEDIA binding so expired-story cleanup also purges the R2 blobs,
82
+ // not just the media_uploads DB rows (otherwise expired-story media leaks).
83
+ cleanupExpiredStories(db, media)
84
+ .catch((err) => {
85
+ log.warn("Failed to cleanup expired stories", {
86
+ event: "stories.cleanup.failed",
87
+ error: err,
88
+ });
89
+ })
90
+ .finally(() => {
91
+ expiredStoryCleanupInFlight = false;
92
+ });
93
+ }
94
+
95
+ // Rate-limit story write paths (publish-like / fanout) per-actor, instead of
96
+ // letting them share the generous general read bucket. Registered as POST
97
+ // middleware ahead of the handlers below so it runs before each write.
98
+ const storyWriteLimiter = rateLimit(RateLimitConfigs.storyWrite);
99
+ stories.post("/", storyWriteLimiter);
100
+ stories.post("/delete", storyWriteLimiter);
101
+
102
+ type VoteResults = Record<number, number>;
103
+
104
+ type StoryAuthor = {
105
+ ap_id: string;
106
+ username: string;
107
+ preferred_username: string | null;
108
+ name: string | null;
109
+ icon_url: string | null;
110
+ };
111
+
112
+ type StoryResponse = {
113
+ ap_id: string;
114
+ author: StoryAuthor;
115
+ attachment: ReturnType<typeof transformStoryData>["attachment"];
116
+ caption?: string;
117
+ displayDuration: string;
118
+ overlays?: ReturnType<typeof transformStoryData>["overlays"];
119
+ end_time: string;
120
+ published: string;
121
+ viewed: boolean;
122
+ like_count: number;
123
+ share_count: number;
124
+ liked: boolean;
125
+ votes?: VoteResults;
126
+ votes_total?: number;
127
+ user_vote?: number;
128
+ };
129
+
130
+ type StoryCreateBody = {
131
+ attachment: {
132
+ r2_key: string;
133
+ content_type: string;
134
+ width?: number;
135
+ height?: number;
136
+ };
137
+ displayDuration: string;
138
+ // Optional caption/text shown over the story.
139
+ caption?: string;
140
+ overlays?: unknown[];
141
+ // Optional community scope. When set, the story is scoped to this community
142
+ // (members-only visibility) instead of the author's personal story feed.
143
+ community_ap_id?: string;
144
+ };
145
+
146
+ // Caption is user-authored free text; cap it so a malformed/huge body can't
147
+ // bloat the stored attachments JSON.
148
+ const MAX_STORY_CAPTION_LENGTH = 500;
149
+ // Hard ceiling on a story-feed query. Stories are a non-paginated "bar", and
150
+ // inbound Create(Story) has no per-author cap, so a hostile followed host could
151
+ // accumulate tens of thousands of live (24h) Story rows and make this
152
+ // authenticated read path (hit on every app open) load an unbounded set into
153
+ // Worker memory. Bound it like every other feed query.
154
+ //
155
+ // Capped at 90 (not 500): the returned story ids are re-queried via
156
+ // `inArray(storyApIds)` for view/like/author enrichment, and Cloudflare D1
157
+ // allows at most 100 bound parameters per query — a 500-item feed would throw
158
+ // "too many SQL variables" on production D1 (libsql, which the tests run on,
159
+ // allows ~32k and hides this). 90 active stories is ample for a single-user
160
+ // feed page; a busy instance simply shows the 90 most recent.
161
+ const MAX_STORY_FEED_ITEMS = 90;
162
+
163
+ /** Build a StoryAuthor from available data sources. */
164
+ function buildAuthor(
165
+ apId: string,
166
+ data:
167
+ | {
168
+ preferredUsername?: string | null;
169
+ name?: string | null;
170
+ iconUrl?: string | null;
171
+ }
172
+ | null
173
+ | undefined,
174
+ ): StoryAuthor {
175
+ return {
176
+ ap_id: apId,
177
+ username: formatUsername(apId),
178
+ preferred_username: data?.preferredUsername || null,
179
+ name: data?.name || null,
180
+ icon_url: data?.iconUrl || null,
181
+ };
182
+ }
183
+
184
+ /** Build a StoryResponse from a story object row and pre-fetched data. */
185
+ function buildStoryResponse(
186
+ s: {
187
+ apId: string;
188
+ attributedTo: string;
189
+ attachmentsJson: string;
190
+ endTime: string | null;
191
+ published: string;
192
+ likeCount: number;
193
+ shareCount: number | null;
194
+ viewedByUser?: boolean;
195
+ likedByUser?: boolean;
196
+ },
197
+ author: StoryAuthor,
198
+ allVotes: Record<string, VoteResults>,
199
+ userVotes: Record<string, number>,
200
+ ): StoryResponse {
201
+ const storyData = transformStoryData(s.attachmentsJson);
202
+ const storyVotesData = allVotes[s.apId] || {};
203
+
204
+ return {
205
+ ap_id: s.apId,
206
+ author,
207
+ attachment: storyData.attachment,
208
+ caption: storyData.caption,
209
+ displayDuration: storyData.displayDuration,
210
+ overlays: storyData.overlays,
211
+ end_time: s.endTime || "",
212
+ published: s.published,
213
+ viewed: s.viewedByUser ?? false,
214
+ like_count: s.likeCount,
215
+ share_count: s.shareCount || 0,
216
+ liked: s.likedByUser ?? false,
217
+ votes: storyVotesData,
218
+ votes_total: sumVotes(storyVotesData),
219
+ user_vote: userVotes[s.apId],
220
+ };
221
+ }
222
+
223
+ /** Resolve remote author info for stories missing a joined author relation. */
224
+ async function resolveRemoteAuthors(
225
+ db: Database,
226
+ storiesData: Array<{ author?: unknown; attributedTo: string }>,
227
+ ): Promise<
228
+ Record<
229
+ string,
230
+ {
231
+ preferredUsername: string | null;
232
+ name: string | null;
233
+ iconUrl: string | null;
234
+ }
235
+ >
236
+ > {
237
+ const remoteIds = [
238
+ ...new Set(storiesData.filter((s) => !s.author).map((s) => s.attributedTo)),
239
+ ];
240
+ return fetchActorCache(db, remoteIds);
241
+ }
242
+
243
+ /** Create an outbound activity record and enqueue fanout to followers. */
244
+ async function createAndFanoutActivity(
245
+ db: Database,
246
+ env: Env,
247
+ actorApIdStr: string,
248
+ objectApIdStr: string,
249
+ activity: Record<string, unknown>,
250
+ communityApId?: string | null,
251
+ ): Promise<void> {
252
+ const id = activity.id as string;
253
+ await db.insert(activities).values({
254
+ apId: id,
255
+ type: activity.type as string,
256
+ actorApId: actorApIdStr,
257
+ objectApId: objectApIdStr,
258
+ rawJson: JSON.stringify(activity),
259
+ direction: "outbound",
260
+ });
261
+ // A community-scoped story has reach == community: fan its activity out to the
262
+ // community's members/followers (the same audience posts use), NOT the
263
+ // author's personal follower graph. A personal story keeps author-follower
264
+ // reach.
265
+ if (communityApId) {
266
+ await enqueueFanoutToCommunity(env, id, communityApId);
267
+ } else {
268
+ await enqueueFanoutToFollowers(env, id, actorApIdStr);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Delete all related data for a story, then the story object itself.
274
+ *
275
+ * Delegates to `deleteObjectCascade` (the SAME teardown the expiry path
276
+ * `cleanupExpiredStories` runs) so it also reaps the story's mandatory R2 blob
277
+ * and its `media_uploads` row — child-row-only deletion here would orphan the
278
+ * image in R2 forever (there is no orphan-key sweep). The cascade covers
279
+ * storyViews/Votes/Shares + likes + announces + bookmarks + objectRecipients +
280
+ * media; it reads `attachments_json` off the still-present object row, so the
281
+ * object row is dropped afterwards.
282
+ */
283
+ /**
284
+ * Returns true when THIS call actually removed the objects row (false if it was
285
+ * already gone). Callers gate the author's postCount decrement on this so a
286
+ * concurrent duplicate delete — or a race with the opportunistic expiry sweep —
287
+ * decrements at most once for the single +1 the story counted at create time.
288
+ */
289
+ async function deleteStoryAndRelatedData(
290
+ db: Database,
291
+ apId: string,
292
+ media?: IObjectStorage,
293
+ ): Promise<boolean> {
294
+ const mediaKeys = await deleteObjectCascade(db, apId, media);
295
+ const deleted = await db
296
+ .delete(objects)
297
+ .where(eq(objects.apId, apId))
298
+ .returning({ apId: objects.apId });
299
+ // Irreversible R2 purge LAST — after the objects row is gone.
300
+ await purgeMediaBlobs(media, mediaKeys);
301
+ return deleted.length > 0;
302
+ }
303
+
304
+ /**
305
+ * Resolve the scope of a stories read request.
306
+ *
307
+ * - No `community` query param -> personal scope (self + followed, and any
308
+ * community-scoped story is excluded so it never leaks into the personal feed).
309
+ * - `community=<apId>` -> community scope; the viewer must be an accepted member
310
+ * of that community, otherwise the scope resolves to "denied" and no stories
311
+ * are returned.
312
+ */
313
+ async function resolveStoryScope(
314
+ db: Database,
315
+ viewerApId: string,
316
+ communityParam: string | undefined,
317
+ ): Promise<
318
+ | { kind: "personal" }
319
+ | { kind: "community"; communityApId: string }
320
+ | { kind: "denied" }
321
+ > {
322
+ if (!communityParam) return { kind: "personal" };
323
+
324
+ const member = await db
325
+ .select({ actorApId: communityMembers.actorApId })
326
+ .from(communityMembers)
327
+ .where(
328
+ and(
329
+ eq(communityMembers.communityApId, communityParam),
330
+ eq(communityMembers.actorApId, viewerApId),
331
+ ),
332
+ )
333
+ .get();
334
+
335
+ if (!member) return { kind: "denied" };
336
+ return { kind: "community", communityApId: communityParam };
337
+ }
338
+
339
+ // Get active stories from followed users and self (grouped by author)
340
+ stories.get("/", async (c) => {
341
+ const actor = c.get("actor");
342
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
343
+
344
+ const db = c.get("db");
345
+ const now = new Date().toISOString();
346
+
347
+ // Resolve the requested scope. `?community=<apId>` switches to community scope
348
+ // (members only); absence keeps the personal self+followed feed.
349
+ const communityParam = c.req.query("community") || undefined;
350
+ const scope = await resolveStoryScope(db, actor.ap_id, communityParam);
351
+ if (scope.kind === "denied") {
352
+ return c.json({ actor_stories: [] });
353
+ }
354
+
355
+ // Opportunistic, best-effort expiry cleanup (see maybeCleanupExpiredStories).
356
+ maybeCleanupExpiredStories(db, c.env.MEDIA);
357
+ // Opportunistically reap drained account tombstones on this hot read path
358
+ // (the Worker has no scheduled handler; see maybeReapDrainedTombstones).
359
+ maybeReapDrainedTombstones(db);
360
+
361
+ // Personal scope shows self + accepted-follows. Express the follow set as a
362
+ // subquery (`attributed_to IN (SELECT ...)`) so the feed stays lossless for
363
+ // any follow count without splicing every followed id into the query as a
364
+ // bound parameter — Cloudflare D1 caps a query at 100, so the old
365
+ // `inArray(followedIds)` 500'd the story feed for anyone following >~100
366
+ // accounts (libsql, which the tests run on, allows ~32k and hid this).
367
+ const followingSubquery = db
368
+ .select({ id: follows.followingApId })
369
+ .from(follows)
370
+ .where(
371
+ and(
372
+ eq(follows.followerApId, actor.ap_id),
373
+ eq(follows.status, "accepted"),
374
+ ),
375
+ );
376
+
377
+ // Scope filter:
378
+ // - community scope: stories whose communityApId = the target community.
379
+ // Author membership in the personal follow graph is irrelevant here; the
380
+ // viewer's accepted membership (verified above) is what grants visibility.
381
+ // - personal scope: self + followed authors, and NO community-scoped story
382
+ // (communityApId IS NULL) so community stories never leak into the feed.
383
+ let storiesWhere =
384
+ scope.kind === "community"
385
+ ? and(
386
+ eq(objects.type, "Story"),
387
+ gt(objects.endTime, now),
388
+ eq(objects.communityApId, scope.communityApId),
389
+ )
390
+ : and(
391
+ eq(objects.type, "Story"),
392
+ gt(objects.endTime, now),
393
+ isNull(objects.communityApId),
394
+ or(
395
+ eq(objects.attributedTo, actor.ap_id),
396
+ inArray(objects.attributedTo, followingSubquery),
397
+ ),
398
+ );
399
+
400
+ const excludeAuthors = excludeBlockedMutedAuthors(db, actor.ap_id);
401
+ if (excludeAuthors) {
402
+ storiesWhere = and(storiesWhere, excludeAuthors);
403
+ }
404
+
405
+ const storiesData = await db
406
+ .select()
407
+ .from(objects)
408
+ .where(storiesWhere!)
409
+ .orderBy(desc(objects.endTime))
410
+ .limit(MAX_STORY_FEED_ITEMS);
411
+
412
+ // Batch fetch views and likes for the current user
413
+ const storyApIds = storiesData.map((s) => s.apId);
414
+
415
+ const [viewedRows, likedRows] = await Promise.all([
416
+ storyApIds.length > 0
417
+ ? db
418
+ .select({ storyApId: storyViews.storyApId })
419
+ .from(storyViews)
420
+ .where(
421
+ and(
422
+ eq(storyViews.actorApId, actor.ap_id),
423
+ inArray(storyViews.storyApId, storyApIds),
424
+ ),
425
+ )
426
+ : [],
427
+ storyApIds.length > 0
428
+ ? db
429
+ .select({ objectApId: likes.objectApId })
430
+ .from(likes)
431
+ .where(
432
+ and(
433
+ eq(likes.actorApId, actor.ap_id),
434
+ inArray(likes.objectApId, storyApIds),
435
+ ),
436
+ )
437
+ : [],
438
+ ]);
439
+
440
+ const viewedSet = new Set(viewedRows.map((v) => v.storyApId));
441
+ const likedSet = new Set(likedRows.map((l) => l.objectApId));
442
+
443
+ // Batch fetch author info
444
+ const authorApIds = [...new Set(storiesData.map((s) => s.attributedTo))];
445
+ const [localAuthors, remoteAuthorCache] = await Promise.all([
446
+ authorApIds.length > 0
447
+ ? db
448
+ .select({
449
+ apId: actors.apId,
450
+ preferredUsername: actors.preferredUsername,
451
+ name: actors.name,
452
+ iconUrl: actors.iconUrl,
453
+ })
454
+ .from(actors)
455
+ .where(inArray(actors.apId, authorApIds))
456
+ : [],
457
+ Promise.resolve().then(async () => {
458
+ // We'll resolve after we know which are remote
459
+ return {} as Record<
460
+ string,
461
+ {
462
+ preferredUsername: string | null;
463
+ name: string | null;
464
+ iconUrl: string | null;
465
+ }
466
+ >;
467
+ }),
468
+ ]);
469
+
470
+ const authorMap = new Map(localAuthors.map((a) => [a.apId, a]));
471
+ const missingAuthorIds = authorApIds.filter((id) => !authorMap.has(id));
472
+ const actorCacheMap = await fetchActorCache(db, missingAuthorIds);
473
+
474
+ const [{ allVotes, userVotes }] = await Promise.all([
475
+ fetchBatchVotes(db, storyApIds, actor.ap_id),
476
+ ]);
477
+
478
+ // Group by author
479
+ const grouped: Record<
480
+ string,
481
+ { actor: StoryAuthor; stories: StoryResponse[]; has_unviewed: boolean }
482
+ > = {};
483
+ const authorOrder: string[] = [];
484
+
485
+ for (const s of storiesData) {
486
+ const authorApId = s.attributedTo;
487
+ const authorData = authorMap.get(authorApId) || actorCacheMap[authorApId];
488
+ const authorInfo = buildAuthor(authorApId, authorData);
489
+
490
+ if (!grouped[authorApId]) {
491
+ grouped[authorApId] = {
492
+ actor: authorInfo,
493
+ stories: [],
494
+ has_unviewed: false,
495
+ };
496
+ if (authorApId === actor.ap_id) {
497
+ authorOrder.unshift(authorApId);
498
+ } else {
499
+ authorOrder.push(authorApId);
500
+ }
501
+ }
502
+
503
+ const response = buildStoryResponse(
504
+ {
505
+ ...s,
506
+ viewedByUser: viewedSet.has(s.apId),
507
+ likedByUser: likedSet.has(s.apId),
508
+ },
509
+ authorInfo,
510
+ allVotes,
511
+ userVotes,
512
+ );
513
+ if (!response.viewed) grouped[authorApId].has_unviewed = true;
514
+ grouped[authorApId].stories.push(response);
515
+ }
516
+
517
+ // Sort stories within each group: unviewed first, then by end_time desc
518
+ for (const group of Object.values(grouped)) {
519
+ group.stories.sort((a, b) => {
520
+ if (!a.viewed && b.viewed) return -1;
521
+ if (a.viewed && !b.viewed) return 1;
522
+ return b.end_time.localeCompare(a.end_time);
523
+ });
524
+ }
525
+
526
+ // Sort author groups: self first, then those with unviewed stories
527
+ authorOrder.sort((a, b) => {
528
+ if (a === actor.ap_id) return -1;
529
+ if (b === actor.ap_id) return 1;
530
+ if (grouped[a].has_unviewed && !grouped[b].has_unviewed) return -1;
531
+ if (!grouped[a].has_unviewed && grouped[b].has_unviewed) return 1;
532
+ return 0;
533
+ });
534
+
535
+ return c.json({ actor_stories: authorOrder.map((apId) => grouped[apId]) });
536
+ });
537
+
538
+ // Get stories for a specific user
539
+ stories.get("/:actorId", async (c) => {
540
+ const targetActorId = c.req.param("actorId");
541
+ const actor = c.get("actor");
542
+ const db = c.get("db");
543
+ const baseUrl = c.env.APP_URL;
544
+ const now = new Date().toISOString();
545
+
546
+ // Find the actor by username or full ap_id
547
+ const targetApId = targetActorId.startsWith("http")
548
+ ? targetActorId
549
+ : actorApId(baseUrl, targetActorId);
550
+
551
+ // Resolve scope. Community scope requires an authenticated, accepted member.
552
+ const communityParam = c.req.query("community") || undefined;
553
+ if (communityParam) {
554
+ if (!actor) return c.json({ stories: [] });
555
+ const scope = await resolveStoryScope(db, actor.ap_id, communityParam);
556
+ if (scope.kind === "denied") return c.json({ stories: [] });
557
+ }
558
+
559
+ // Check blocked/muted (if authenticated)
560
+ if (actor) {
561
+ const { blockedIds, mutedIds } = await fetchBlockedAndMutedIds(
562
+ db,
563
+ actor.ap_id,
564
+ );
565
+ if (blockedIds.includes(targetApId) || mutedIds.includes(targetApId)) {
566
+ return c.json({ stories: [] });
567
+ }
568
+ }
569
+
570
+ // Personal stories are follower-scoped: the home feed (`GET /`) only surfaces
571
+ // self + accepted-follows, and the federated Create addresses them to the
572
+ // author's /followers — so a personal-scope read of another actor's stories is
573
+ // gated to the target or an accepted follower (and never anonymous). Community
574
+ // scope is already gated above by `resolveStoryScope`.
575
+ if (!communityParam) {
576
+ if (!actor) return c.json({ stories: [] });
577
+ if (actor.ap_id !== targetApId) {
578
+ const follow = await db
579
+ .select({ followerApId: follows.followerApId })
580
+ .from(follows)
581
+ .where(
582
+ and(
583
+ eq(follows.followerApId, actor.ap_id),
584
+ eq(follows.followingApId, targetApId),
585
+ eq(follows.status, "accepted"),
586
+ ),
587
+ )
588
+ .get();
589
+ if (!follow) return c.json({ stories: [] });
590
+ }
591
+ }
592
+
593
+ // Get stories for the target user, filtered by scope:
594
+ // - community scope: only that community's stories,
595
+ // - personal scope: only NON-community (personal) stories.
596
+ const scopeCondition = communityParam
597
+ ? eq(objects.communityApId, communityParam)
598
+ : isNull(objects.communityApId);
599
+
600
+ const userStories = await db
601
+ .select()
602
+ .from(objects)
603
+ .where(
604
+ and(
605
+ eq(objects.type, "Story"),
606
+ eq(objects.attributedTo, targetApId),
607
+ gt(objects.endTime, now),
608
+ scopeCondition,
609
+ ),
610
+ )
611
+ .orderBy(desc(objects.published))
612
+ .limit(MAX_STORY_FEED_ITEMS);
613
+
614
+ const storyApIds = userStories.map((s) => s.apId);
615
+
616
+ // Batch fetch views and likes for current user
617
+ const [viewedRows, likedRows] = await Promise.all([
618
+ actor && storyApIds.length > 0
619
+ ? db
620
+ .select({ storyApId: storyViews.storyApId })
621
+ .from(storyViews)
622
+ .where(
623
+ and(
624
+ eq(storyViews.actorApId, actor.ap_id),
625
+ inArray(storyViews.storyApId, storyApIds),
626
+ ),
627
+ )
628
+ : [],
629
+ actor && storyApIds.length > 0
630
+ ? db
631
+ .select({ objectApId: likes.objectApId })
632
+ .from(likes)
633
+ .where(
634
+ and(
635
+ eq(likes.actorApId, actor.ap_id),
636
+ inArray(likes.objectApId, storyApIds),
637
+ ),
638
+ )
639
+ : [],
640
+ ]);
641
+
642
+ const viewedSet = new Set((viewedRows || []).map((v) => v.storyApId));
643
+ const likedSet = new Set((likedRows || []).map((l) => l.objectApId));
644
+
645
+ // Batch fetch author info
646
+ const authorApIds = [...new Set(userStories.map((s) => s.attributedTo))];
647
+ const localAuthors =
648
+ authorApIds.length > 0
649
+ ? await db
650
+ .select({
651
+ apId: actors.apId,
652
+ preferredUsername: actors.preferredUsername,
653
+ name: actors.name,
654
+ iconUrl: actors.iconUrl,
655
+ })
656
+ .from(actors)
657
+ .where(inArray(actors.apId, authorApIds))
658
+ : [];
659
+
660
+ const authorLocalMap = new Map(localAuthors.map((a) => [a.apId, a]));
661
+ const missingIds = authorApIds.filter((id) => !authorLocalMap.has(id));
662
+ const actorCacheMap = await fetchActorCache(db, missingIds);
663
+
664
+ const [{ allVotes, userVotes }] = await Promise.all([
665
+ fetchBatchVotes(db, storyApIds, actor?.ap_id),
666
+ ]);
667
+
668
+ const result = userStories.map((s) => {
669
+ const authorData =
670
+ authorLocalMap.get(s.attributedTo) || actorCacheMap[s.attributedTo];
671
+ const author = buildAuthor(s.attributedTo, authorData);
672
+
673
+ return buildStoryResponse(
674
+ {
675
+ ...s,
676
+ viewedByUser: viewedSet.has(s.apId),
677
+ likedByUser: likedSet.has(s.apId),
678
+ },
679
+ author,
680
+ allVotes,
681
+ userVotes,
682
+ );
683
+ });
684
+
685
+ return c.json({ stories: result });
686
+ });
687
+
688
+ // Create story (v2: single attachment format)
689
+ stories.post("/", async (c) => {
690
+ const actor = c.get("actor");
691
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
692
+
693
+ const db = c.get("db");
694
+ const body = await c.req.json<StoryCreateBody>();
695
+
696
+ if (!body.attachment || !body.attachment.r2_key) {
697
+ return c.json({ error: "attachment with r2_key required" }, 400);
698
+ }
699
+
700
+ if (body.overlays && body.overlays.length > 0) {
701
+ const validation = validateOverlays(body.overlays);
702
+ if (!validation.valid) {
703
+ return c.json({ error: validation.error }, 400);
704
+ }
705
+ }
706
+
707
+ // Normalize the optional caption: trim, drop when empty, and reject overlong
708
+ // input rather than silently truncating.
709
+ let caption: string | undefined;
710
+ if (typeof body.caption === "string") {
711
+ const trimmed = body.caption.trim();
712
+ if (trimmed.length > MAX_STORY_CAPTION_LENGTH) {
713
+ return c.json(
714
+ {
715
+ error: `caption must be at most ${MAX_STORY_CAPTION_LENGTH} characters`,
716
+ },
717
+ 400,
718
+ );
719
+ }
720
+ if (trimmed.length > 0) caption = trimmed;
721
+ }
722
+
723
+ // Optional community scope. Reuse the same post-permission policy as posts so
724
+ // story scope and post scope stay consistent (membership + postPolicy). A
725
+ // personal story leaves communityApId NULL.
726
+ const communityCheck = await checkCommunityPostPermission(
727
+ db,
728
+ actor.ap_id,
729
+ body.community_ap_id,
730
+ );
731
+ if (!communityCheck.allowed) {
732
+ return c.json({ error: communityCheck.error }, communityCheck.status);
733
+ }
734
+ const communityApIdValue = communityCheck.communityId;
735
+ const communityFollowersUrl = communityCheck.community?.followersUrl ?? null;
736
+
737
+ const baseUrl = c.env.APP_URL;
738
+ const apId = objectApId(baseUrl, generateId());
739
+ const now = new Date().toISOString();
740
+ const endTime = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
741
+
742
+ const storyData = {
743
+ attachment: {
744
+ ...body.attachment,
745
+ width: body.attachment.width || 1080,
746
+ height: body.attachment.height || 1920,
747
+ },
748
+ displayDuration: body.displayDuration || "PT5S",
749
+ caption,
750
+ overlays: body.overlays || undefined,
751
+ };
752
+ const attachmentsJson = JSON.stringify(storyData);
753
+
754
+ // Insert the story object and bump the author's denormalized postCount in a
755
+ // single atomic batch. D1 has no interactive transactions, so doing these as
756
+ // two separate writes could drift the counter relative to the stored stories
757
+ // on a mid-request failure. The `Database` union type does not surface `batch`
758
+ // (it is only on the concrete D1/libsql subclasses), so reach it through a
759
+ // narrow structural cast.
760
+ const storyInsert = db.insert(objects).values({
761
+ apId,
762
+ type: "Story",
763
+ attributedTo: actor.ap_id,
764
+ content: "",
765
+ attachmentsJson,
766
+ communityApId: communityApIdValue,
767
+ endTime,
768
+ published: now,
769
+ isLocal: 1,
770
+ });
771
+
772
+ const postCountBump = db
773
+ .update(actors)
774
+ .set({ postCount: sql`${actors.postCount} + 1` })
775
+ .where(eq(actors.apId, actor.ap_id));
776
+
777
+ await (db as unknown as Batchable).batch([storyInsert, postCountBump]);
778
+
779
+ const responseData = transformStoryData(attachmentsJson);
780
+ const authorInfo = buildAuthor(actor.ap_id, {
781
+ preferredUsername: actor.preferred_username,
782
+ name: actor.name,
783
+ iconUrl: actor.icon_url,
784
+ });
785
+
786
+ const story = {
787
+ ap_id: apId,
788
+ author: authorInfo,
789
+ attachment: responseData.attachment,
790
+ caption: responseData.caption,
791
+ displayDuration: responseData.displayDuration,
792
+ overlays: responseData.overlays,
793
+ end_time: endTime,
794
+ published: now,
795
+ viewed: false,
796
+ like_count: 0,
797
+ liked: false,
798
+ };
799
+
800
+ // Send Create(Story) activity to followers
801
+ const storyObject = storyToActivityPub(
802
+ {
803
+ apId,
804
+ attributedTo: actor.ap_id,
805
+ attachment: responseData.attachment,
806
+ displayDuration: responseData.displayDuration,
807
+ caption: responseData.caption,
808
+ overlays: responseData.overlays,
809
+ endTime,
810
+ published: now,
811
+ },
812
+ actor,
813
+ baseUrl,
814
+ );
815
+ // Address a community-scoped story to the community's followers collection;
816
+ // a personal story stays addressed to the author's own followers.
817
+ const storyTo =
818
+ communityApIdValue && communityFollowersUrl
819
+ ? [communityFollowersUrl]
820
+ : [`${actor.ap_id}/followers`];
821
+ await createAndFanoutActivity(
822
+ db,
823
+ c.env,
824
+ actor.ap_id,
825
+ apId,
826
+ {
827
+ "@context": "https://www.w3.org/ns/activitystreams",
828
+ id: activityApId(baseUrl, generateId()),
829
+ type: "Create",
830
+ actor: actor.ap_id,
831
+ published: now,
832
+ to: storyTo,
833
+ object: storyObject,
834
+ },
835
+ communityApIdValue,
836
+ );
837
+
838
+ return c.json({ story }, 201);
839
+ });
840
+
841
+ // Delete story
842
+ stories.post("/delete", async (c) => {
843
+ const actor = c.get("actor");
844
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
845
+
846
+ const db = c.get("db");
847
+ const body = await c.req.json<{ ap_id: string }>();
848
+ if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
849
+ const apId = body.ap_id;
850
+
851
+ // Verify ownership
852
+ const story = await db
853
+ .select()
854
+ .from(objects)
855
+ .where(eq(objects.apId, apId))
856
+ .get();
857
+ if (!story) return c.json({ error: "Story not found" }, 404);
858
+ if (story.attributedTo !== actor.ap_id) {
859
+ return c.json({ error: "Forbidden" }, 403);
860
+ }
861
+
862
+ // Enqueue Delete(Story) activity before deleting. Outbound delivery MUST NOT
863
+ // run in request path; enqueue is the sync boundary. A community-scoped story
864
+ // tombstone is addressed to and fanned out to the community (reach ==
865
+ // community), mirroring its Create; a personal story keeps author-follower
866
+ // reach.
867
+ const baseUrl = c.env.APP_URL;
868
+ const deleteTo = story.communityApId
869
+ ? [`${story.communityApId}/followers`]
870
+ : ["https://www.w3.org/ns/activitystreams#Public"];
871
+ await createAndFanoutActivity(
872
+ db,
873
+ c.env,
874
+ actor.ap_id,
875
+ apId,
876
+ {
877
+ "@context": "https://www.w3.org/ns/activitystreams",
878
+ id: activityApId(baseUrl, generateId()),
879
+ type: "Delete",
880
+ actor: actor.ap_id,
881
+ to: deleteTo,
882
+ object: apId,
883
+ },
884
+ story.communityApId,
885
+ );
886
+
887
+ const removed = await deleteStoryAndRelatedData(db, apId, c.env.MEDIA);
888
+
889
+ // Decrement the author's postCount ONLY when THIS request actually removed the
890
+ // row. The early 404 above guards SEQUENTIAL duplicates, but two concurrent
891
+ // deletes (double-click / retry) — or a manual delete racing the opportunistic
892
+ // expiry sweep (cleanupExpiredStories) — can both pass the SELECT before either
893
+ // delete commits, then both reach here; an unconditional decrement would then
894
+ // subtract 2 for one +1. Gating on the actual delete keeps the count exact
895
+ // (gt > 0 still guards underflow). Mirrors the EXISTS-guarded post-delete path.
896
+ if (removed) {
897
+ await db
898
+ .update(actors)
899
+ .set({ postCount: sql`${actors.postCount} - 1` })
900
+ .where(and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0)));
901
+ }
902
+
903
+ return c.json({ success: true });
904
+ });
905
+
906
+ export default stories;