@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,795 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+ import type { Env, Variables } from "../../types.ts";
4
+ import type { Database } from "../../../db/index.ts";
5
+ import {
6
+ activities,
7
+ actorCache,
8
+ actors,
9
+ announces,
10
+ bookmarks,
11
+ follows,
12
+ inbox as inboxTable,
13
+ likes,
14
+ objects,
15
+ } from "../../../db/index.ts";
16
+ import { and, desc, eq, gt, inArray, or, sql } from "drizzle-orm";
17
+ import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
18
+ import type { BatchItem } from "drizzle-orm/batch";
19
+ import {
20
+ activityApId,
21
+ formatUsername,
22
+ generateId,
23
+ isLocal,
24
+ objectApId,
25
+ parseLimit,
26
+ safeJsonParse,
27
+ } from "../../federation-helpers.ts";
28
+ import { MAX_POSTS_PAGE_LIMIT } from "./transformers.ts";
29
+ import {
30
+ enqueueDeliveryToActor,
31
+ enqueueFanoutToFollowers,
32
+ } from "../../lib/delivery/queue.ts";
33
+ import { communityReadableApIds } from "../../lib/community-visibility.ts";
34
+ import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
35
+ import {
36
+ actorIsBlockedBy,
37
+ canViewerReadObjectFull,
38
+ passesPostVisibilitySync,
39
+ } from "../../lib/post-visibility.ts";
40
+ import { logger } from "../../lib/logger.ts";
41
+
42
+ const log = logger.child({ component: "posts.interactions" });
43
+
44
+ type AppContext = Context<{ Bindings: Env; Variables: Variables }>;
45
+
46
+ const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Shared helpers (file-local)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Look up a post by local ID or full AP ID. Returns null when not found. */
53
+ async function findPost(
54
+ c: AppContext,
55
+ ): Promise<typeof objects.$inferSelect | null>;
56
+ async function findPost(
57
+ c: AppContext,
58
+ selectFields: "apIdOnly",
59
+ ): Promise<{ apId: string; attributedTo: string } | null>;
60
+ async function findPost(
61
+ c: AppContext,
62
+ selectFields?: "apIdOnly",
63
+ ): Promise<
64
+ | typeof objects.$inferSelect
65
+ | { apId: string; attributedTo: string }
66
+ | null
67
+ | undefined
68
+ > {
69
+ const db = c.get("db");
70
+ const postId = c.req.param("id")!;
71
+ const baseUrl = c.env.APP_URL;
72
+
73
+ const whereCondition = or(
74
+ eq(objects.apId, objectApId(baseUrl, postId)),
75
+ eq(objects.apId, postId),
76
+ );
77
+
78
+ if (selectFields === "apIdOnly") {
79
+ return (
80
+ db
81
+ .select({ apId: objects.apId, attributedTo: objects.attributedTo })
82
+ .from(objects)
83
+ .where(whereCondition)
84
+ .get() ?? null
85
+ );
86
+ }
87
+
88
+ return (
89
+ db.query.objects.findFirst({
90
+ where: whereCondition,
91
+ }) ?? null
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Best-effort delivery of an activity to a remote actor.
97
+ * Errors are logged but never propagated.
98
+ */
99
+ async function deliverToRemote(
100
+ env: Env,
101
+ activityId: string,
102
+ recipientApId: string,
103
+ ): Promise<void> {
104
+ try {
105
+ await enqueueDeliveryToActor(env, activityId, recipientApId);
106
+ } catch (err) {
107
+ log.error("Failed to enqueue delivery", {
108
+ event: "posts.delivery.enqueue_failed",
109
+ activityId,
110
+ recipient: recipientApId,
111
+ error: err,
112
+ });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Atomic multi-statement commit.
118
+ *
119
+ * D1 has no interactive transactions, but both the D1 and libsql drivers
120
+ * expose `db.batch([...])`, which commits a list of prepared statements
121
+ * atomically. The shared `Database` union aliases the abstract
122
+ * `BaseSQLiteDatabase` base (which does not surface `batch`), so we narrow to
123
+ * the concrete batch surface here rather than weakening the shared type.
124
+ */
125
+ type BatchStatement = BatchItem<"sqlite">;
126
+ interface BatchableDb {
127
+ batch(
128
+ statements: readonly [BatchStatement, ...BatchStatement[]],
129
+ ): Promise<unknown>;
130
+ }
131
+
132
+ async function runBatch(
133
+ db: Database,
134
+ statements: readonly [BatchStatement, ...BatchStatement[]],
135
+ ): Promise<void> {
136
+ await (db as unknown as BatchableDb).batch(statements);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Like / Unlike
141
+ // ---------------------------------------------------------------------------
142
+
143
+ posts.post("/:id/like", async (c) => {
144
+ const actor = c.get("actor");
145
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
146
+
147
+ const post = await findPost(c);
148
+ if (!post) return c.json({ error: "Post not found" }, 404);
149
+
150
+ const db = c.get("db");
151
+ const baseUrl = c.env.APP_URL;
152
+
153
+ // Only a post the actor can actually read may be liked: without this an authed
154
+ // user who learns a post's apId could "like" a followers-only / direct /
155
+ // private-community post they were never shown, notifying the author and
156
+ // bumping the count from an unentitled actor. 404 (not 403) so existence is
157
+ // not revealed, mirroring the post-detail gate. `post` is the full row loaded
158
+ // above, so the read-gate runs against it directly.
159
+ if (!(await canViewerReadObjectFull(db, post, actor.ap_id))) {
160
+ return c.json({ error: "Post not found" }, 404);
161
+ }
162
+ // A blocked actor must not be able to like (bump likeCount + notify) the author
163
+ // who blocked them — mirror the story-like / DM block guard (404, not 403).
164
+ if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
165
+ return c.json({ error: "Post not found" }, 404);
166
+ }
167
+
168
+ const existingLike = await db
169
+ .select({ actorApId: likes.actorApId })
170
+ .from(likes)
171
+ .where(
172
+ and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
173
+ )
174
+ .get();
175
+ if (existingLike) return c.json({ error: "Already liked" }, 400);
176
+
177
+ const likeId = generateId();
178
+ const likeActivityId = activityApId(baseUrl, likeId);
179
+ const likeActivityRaw = {
180
+ "@context": "https://www.w3.org/ns/activitystreams",
181
+ id: likeActivityId,
182
+ type: "Like",
183
+ actor: actor.ap_id,
184
+ object: post.apId,
185
+ };
186
+ const now = new Date().toISOString();
187
+ const shouldNotifyLocal =
188
+ post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
189
+
190
+ // D1 has no interactive transactions; group the child-row insert, counter
191
+ // bump, activity row, and (optional) inbox row into a single atomic batch so
192
+ // the like row and likeCount can never diverge on a mid-request failure.
193
+ const likeStatements: BatchStatement[] = [
194
+ db.insert(likes).values({
195
+ actorApId: actor.ap_id,
196
+ objectApId: post.apId,
197
+ activityApId: likeActivityId,
198
+ }),
199
+ db
200
+ .update(objects)
201
+ .set({ likeCount: sql`${objects.likeCount} + 1` })
202
+ .where(eq(objects.apId, post.apId)),
203
+ db.insert(activities).values({
204
+ apId: likeActivityId,
205
+ type: "Like",
206
+ actorApId: actor.ap_id,
207
+ objectApId: post.apId,
208
+ rawJson: JSON.stringify(likeActivityRaw),
209
+ createdAt: now,
210
+ }),
211
+ ];
212
+
213
+ if (shouldNotifyLocal) {
214
+ likeStatements.push(
215
+ db.insert(inboxTable).values({
216
+ actorApId: post.attributedTo,
217
+ activityApId: likeActivityId,
218
+ read: 0,
219
+ createdAt: now,
220
+ }),
221
+ );
222
+ }
223
+
224
+ try {
225
+ await runBatch(db, likeStatements as [BatchStatement, ...BatchStatement[]]);
226
+ } catch (e) {
227
+ // Two concurrent likes (two tabs / a retried slow request) both pass the
228
+ // SELECT above; the loser's composite-PK insert hits a UNIQUE constraint.
229
+ // That is the idempotent "already liked" case, not a 500.
230
+ if (isUniqueConstraintError(e)) {
231
+ return c.json({ error: "Already liked" }, 400);
232
+ }
233
+ throw e;
234
+ }
235
+
236
+ if (!isLocal(post.apId, baseUrl)) {
237
+ await deliverToRemote(c.env, likeActivityId, post.attributedTo);
238
+ }
239
+
240
+ return c.json({ success: true, liked: true });
241
+ });
242
+
243
+ posts.delete("/:id/like", async (c) => {
244
+ const actor = c.get("actor");
245
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
246
+
247
+ const post = await findPost(c);
248
+ if (!post) return c.json({ error: "Post not found" }, 404);
249
+
250
+ const db = c.get("db");
251
+ const baseUrl = c.env.APP_URL;
252
+
253
+ const like = await db
254
+ .select({
255
+ actorApId: likes.actorApId,
256
+ activityApId: likes.activityApId,
257
+ })
258
+ .from(likes)
259
+ .where(
260
+ and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
261
+ )
262
+ .get();
263
+ if (!like) return c.json({ error: "Not liked" }, 400);
264
+
265
+ // D1 has no interactive transactions; group the like-row delete and the
266
+ // counter decrement into a single atomic batch so they cannot diverge. Also
267
+ // reap the original like's notification: the like mints a FRESH activity id
268
+ // each time and the dedup guard only checks the `likes` edge, so without this
269
+ // a like→unlike→like cycle would leave the first notification behind and add a
270
+ // second — duplicate "X liked your post" rows (and a phantom unread +1). The
271
+ // inbound-federated path already gates its notify on the existing edge; this
272
+ // gives the local path the same idempotency.
273
+ const reapLikeNotification: BatchStatement[] = like.activityApId
274
+ ? [
275
+ db
276
+ .delete(inboxTable)
277
+ .where(eq(inboxTable.activityApId, like.activityApId)),
278
+ db.delete(activities).where(eq(activities.apId, like.activityApId)),
279
+ ]
280
+ : [];
281
+ await runBatch(db, [
282
+ db
283
+ .delete(likes)
284
+ .where(
285
+ and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
286
+ ),
287
+ db
288
+ .update(objects)
289
+ .set({ likeCount: sql`${objects.likeCount} - 1` })
290
+ .where(and(eq(objects.apId, post.apId), gt(objects.likeCount, 0))),
291
+ ...reapLikeNotification,
292
+ ]);
293
+
294
+ if (!isLocal(post.apId, baseUrl)) {
295
+ const undoObject = like.activityApId
296
+ ? like.activityApId
297
+ : { type: "Like", actor: actor.ap_id, object: post.apId };
298
+
299
+ const undoActivity = {
300
+ "@context": "https://www.w3.org/ns/activitystreams",
301
+ id: activityApId(baseUrl, generateId()),
302
+ type: "Undo",
303
+ actor: actor.ap_id,
304
+ object: undoObject,
305
+ };
306
+
307
+ await db
308
+ .insert(activities)
309
+ .values({
310
+ apId: undoActivity.id,
311
+ type: "Undo",
312
+ actorApId: actor.ap_id,
313
+ objectApId: post.apId,
314
+ rawJson: JSON.stringify(undoActivity),
315
+ direction: "outbound",
316
+ })
317
+ .onConflictDoNothing();
318
+
319
+ await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
320
+ }
321
+
322
+ return c.json({ success: true, liked: false });
323
+ });
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Repost (Announce) / Unrepost (Undo Announce)
327
+ // ---------------------------------------------------------------------------
328
+
329
+ posts.post("/:id/repost", async (c) => {
330
+ const actor = c.get("actor");
331
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
332
+
333
+ const post = await findPost(c);
334
+ if (!post) return c.json({ error: "Post not found" }, 404);
335
+
336
+ const db = c.get("db");
337
+ const baseUrl = c.env.APP_URL;
338
+
339
+ // A repost is always an Announce addressed to Public + the booster's
340
+ // followers (see the activity built below), so it re-broadcasts the object to
341
+ // a wider audience than the original. Only a post whose reach is ALREADY
342
+ // public may be boosted: reposting a followers-only / direct / community-
343
+ // scoped post would leak restricted content out to the public (Mastodon
344
+ // likewise forbids boosting followers-only and direct posts). "Truly public"
345
+ // = visibility public/unlisted AND no community/addressed audience (an empty
346
+ // audienceJson — community feed + chat posts both carry a non-empty audience).
347
+ // `post` is the full row loaded above, so reach is read from it directly.
348
+ const boostable =
349
+ // A Story is stored visibility="public"/audienceJson="[]" but its reach is
350
+ // followers-only and it is ephemeral — boosting it would re-broadcast
351
+ // follower-scoped content to the booster's public audience past its lifetime.
352
+ post.type !== "Story" &&
353
+ (post.visibility === "public" || post.visibility === "unlisted") &&
354
+ post.audienceJson === "[]";
355
+ if (!boostable) {
356
+ return c.json({ error: "This post cannot be reposted" }, 403);
357
+ }
358
+ // A blocked actor must not be able to repost (bump announceCount + notify) the
359
+ // author who blocked them — mirror the story-like / DM block guard (404).
360
+ if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
361
+ return c.json({ error: "Post not found" }, 404);
362
+ }
363
+
364
+ const existingRepost = await db
365
+ .select({ actorApId: announces.actorApId })
366
+ .from(announces)
367
+ .where(
368
+ and(
369
+ eq(announces.actorApId, actor.ap_id),
370
+ eq(announces.objectApId, post.apId),
371
+ ),
372
+ )
373
+ .get();
374
+ if (existingRepost) return c.json({ error: "Already reposted" }, 400);
375
+
376
+ const announceId = generateId();
377
+ const announceActivityId = activityApId(baseUrl, announceId);
378
+ const announceActivityRaw = {
379
+ "@context": "https://www.w3.org/ns/activitystreams",
380
+ id: announceActivityId,
381
+ type: "Announce",
382
+ actor: actor.ap_id,
383
+ object: post.apId,
384
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
385
+ cc: [actor.ap_id + "/followers"],
386
+ };
387
+ const now = new Date().toISOString();
388
+ const shouldNotifyLocal =
389
+ post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
390
+
391
+ // D1 has no interactive transactions; group the child-row insert, counter
392
+ // bump, activity row, and (optional) inbox row into a single atomic batch so
393
+ // the announce row and announceCount can never diverge.
394
+ const repostStatements: BatchStatement[] = [
395
+ db.insert(announces).values({
396
+ actorApId: actor.ap_id,
397
+ objectApId: post.apId,
398
+ activityApId: announceActivityId,
399
+ }),
400
+ db
401
+ .update(objects)
402
+ .set({ announceCount: sql`${objects.announceCount} + 1` })
403
+ .where(eq(objects.apId, post.apId)),
404
+ db.insert(activities).values({
405
+ apId: announceActivityId,
406
+ type: "Announce",
407
+ actorApId: actor.ap_id,
408
+ objectApId: post.apId,
409
+ rawJson: JSON.stringify(announceActivityRaw),
410
+ createdAt: now,
411
+ }),
412
+ ];
413
+
414
+ if (shouldNotifyLocal) {
415
+ repostStatements.push(
416
+ db.insert(inboxTable).values({
417
+ actorApId: post.attributedTo,
418
+ activityApId: announceActivityId,
419
+ read: 0,
420
+ createdAt: now,
421
+ }),
422
+ );
423
+ }
424
+
425
+ try {
426
+ await runBatch(
427
+ db,
428
+ repostStatements as [BatchStatement, ...BatchStatement[]],
429
+ );
430
+ } catch (e) {
431
+ // Concurrent duplicate repost → composite-PK UNIQUE → idempotent 400.
432
+ if (isUniqueConstraintError(e)) {
433
+ return c.json({ error: "Already reposted" }, 400);
434
+ }
435
+ throw e;
436
+ }
437
+
438
+ // The Announce is cc'd to the booster's own followers collection, so fan it
439
+ // out to them — otherwise a repost only ever reached the original author's
440
+ // inbox and was invisible to the booster's remote followers.
441
+ await enqueueFanoutToFollowers(c.env, announceActivityId, actor.ap_id);
442
+
443
+ if (!isLocal(post.apId, baseUrl)) {
444
+ await deliverToRemote(c.env, announceActivityId, post.attributedTo);
445
+ }
446
+
447
+ return c.json({ success: true, reposted: true });
448
+ });
449
+
450
+ posts.delete("/:id/repost", async (c) => {
451
+ const actor = c.get("actor");
452
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
453
+
454
+ const post = await findPost(c);
455
+ if (!post) return c.json({ error: "Post not found" }, 404);
456
+
457
+ const db = c.get("db");
458
+ const baseUrl = c.env.APP_URL;
459
+
460
+ const announce = await db
461
+ .select({
462
+ actorApId: announces.actorApId,
463
+ activityApId: announces.activityApId,
464
+ })
465
+ .from(announces)
466
+ .where(
467
+ and(
468
+ eq(announces.actorApId, actor.ap_id),
469
+ eq(announces.objectApId, post.apId),
470
+ ),
471
+ )
472
+ .get();
473
+ if (!announce) return c.json({ error: "Not reposted" }, 400);
474
+
475
+ // D1 has no interactive transactions; group the announce-row delete and the
476
+ // counter decrement into a single atomic batch so they cannot diverge. Also
477
+ // reap the original repost's notification (same duplicate-notification issue
478
+ // as unlike: each repost mints a fresh Announce activity id and the guard only
479
+ // checks the edge, so repost→unrepost→repost would stack notifications).
480
+ const reapRepostNotification: BatchStatement[] = announce.activityApId
481
+ ? [
482
+ db
483
+ .delete(inboxTable)
484
+ .where(eq(inboxTable.activityApId, announce.activityApId)),
485
+ db.delete(activities).where(eq(activities.apId, announce.activityApId)),
486
+ ]
487
+ : [];
488
+ await runBatch(db, [
489
+ db
490
+ .delete(announces)
491
+ .where(
492
+ and(
493
+ eq(announces.actorApId, actor.ap_id),
494
+ eq(announces.objectApId, post.apId),
495
+ ),
496
+ ),
497
+ db
498
+ .update(objects)
499
+ .set({ announceCount: sql`${objects.announceCount} - 1` })
500
+ .where(and(eq(objects.apId, post.apId), gt(objects.announceCount, 0))),
501
+ ...reapRepostNotification,
502
+ ]);
503
+
504
+ const undoActivity = {
505
+ "@context": "https://www.w3.org/ns/activitystreams",
506
+ id: activityApId(baseUrl, generateId()),
507
+ type: "Undo",
508
+ actor: actor.ap_id,
509
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
510
+ cc: [actor.ap_id + "/followers"],
511
+ object: { type: "Announce", actor: actor.ap_id, object: post.apId },
512
+ };
513
+
514
+ await db
515
+ .insert(activities)
516
+ .values({
517
+ apId: undoActivity.id,
518
+ type: "Undo",
519
+ actorApId: actor.ap_id,
520
+ objectApId: post.apId,
521
+ rawJson: JSON.stringify(undoActivity),
522
+ direction: "outbound",
523
+ })
524
+ .onConflictDoNothing();
525
+
526
+ // Mirror the Announce reach: the Undo must reach the booster's followers (who
527
+ // saw the repost) regardless of whether the boosted post was local or remote.
528
+ await enqueueFanoutToFollowers(c.env, undoActivity.id, actor.ap_id);
529
+
530
+ // The boosted post's author instance is only an extra recipient for a remote post.
531
+ if (!isLocal(post.apId, baseUrl)) {
532
+ await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
533
+ }
534
+
535
+ return c.json({ success: true, reposted: false });
536
+ });
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Bookmark / Unbookmark / List bookmarks
540
+ // ---------------------------------------------------------------------------
541
+
542
+ posts.post("/:id/bookmark", async (c) => {
543
+ const actor = c.get("actor");
544
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
545
+
546
+ // Load the FULL row (not apId-only) so the read-gate can run: previously this
547
+ // resolved by apId with NO visibility/block check, so any authed user could
548
+ // bookmark any object by id — including a followers-only / direct / private-
549
+ // community post or a personal Story they were never shown — which then leaked
550
+ // via GET /bookmarks. Gate it like /like and /repost (#3).
551
+ const post = await findPost(c);
552
+ if (!post) return c.json({ error: "Post not found" }, 404);
553
+
554
+ const db = c.get("db");
555
+
556
+ // 404 (not 403) so existence is not revealed, mirroring the like/repost gate.
557
+ if (!(await canViewerReadObjectFull(db, post, actor.ap_id))) {
558
+ return c.json({ error: "Post not found" }, 404);
559
+ }
560
+ if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
561
+ return c.json({ error: "Post not found" }, 404);
562
+ }
563
+
564
+ const existing = await db
565
+ .select({ actorApId: bookmarks.actorApId })
566
+ .from(bookmarks)
567
+ .where(
568
+ and(
569
+ eq(bookmarks.actorApId, actor.ap_id),
570
+ eq(bookmarks.objectApId, post.apId),
571
+ ),
572
+ )
573
+ .get();
574
+ if (existing) return c.json({ error: "Already bookmarked" }, 400);
575
+
576
+ // onConflictDoNothing so two concurrent bookmarks of the same post (two tabs /
577
+ // a retried slow request) that both pass the existence check don't 500 the
578
+ // loser on the (actorApId, objectApId) composite PK — the edge is idempotent.
579
+ await db
580
+ .insert(bookmarks)
581
+ .values({
582
+ actorApId: actor.ap_id,
583
+ objectApId: post.apId,
584
+ })
585
+ .onConflictDoNothing();
586
+
587
+ return c.json({ success: true, bookmarked: true });
588
+ });
589
+
590
+ posts.delete("/:id/bookmark", async (c) => {
591
+ const actor = c.get("actor");
592
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
593
+
594
+ const post = await findPost(c, "apIdOnly");
595
+ if (!post) return c.json({ error: "Post not found" }, 404);
596
+
597
+ const db = c.get("db");
598
+
599
+ const bookmark = await db
600
+ .select({ actorApId: bookmarks.actorApId })
601
+ .from(bookmarks)
602
+ .where(
603
+ and(
604
+ eq(bookmarks.actorApId, actor.ap_id),
605
+ eq(bookmarks.objectApId, post.apId),
606
+ ),
607
+ )
608
+ .get();
609
+ if (!bookmark) return c.json({ error: "Not bookmarked" }, 400);
610
+
611
+ await db
612
+ .delete(bookmarks)
613
+ .where(
614
+ and(
615
+ eq(bookmarks.actorApId, actor.ap_id),
616
+ eq(bookmarks.objectApId, post.apId),
617
+ ),
618
+ );
619
+
620
+ return c.json({ success: true, bookmarked: false });
621
+ });
622
+
623
+ posts.get("/bookmarks", async (c) => {
624
+ const actor = c.get("actor");
625
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
626
+
627
+ const db = c.get("db");
628
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_POSTS_PAGE_LIMIT);
629
+ const before = c.req.query("before");
630
+
631
+ // Composite (createdAt, objectApId) cursor — createdAt is not unique, so a
632
+ // bare cursor would skip same-timestamp bookmarks at a boundary; objectApId is
633
+ // the unique tiebreaker (see lib/feed-cursor.ts).
634
+ const cursorPredicate = feedCursorWhere(
635
+ bookmarks.createdAt,
636
+ bookmarks.objectApId,
637
+ before,
638
+ );
639
+
640
+ // Fetch limit+1 and advance the cursor by the last SCANNED row, so the
641
+ // visibility gate dropping rows can only shorten a page, never make a readable
642
+ // bookmark permanently unreachable (load-more continues past the dropped ones).
643
+ const scanned = await db.query.bookmarks.findMany({
644
+ where: cursorPredicate
645
+ ? and(eq(bookmarks.actorApId, actor.ap_id), cursorPredicate)
646
+ : eq(bookmarks.actorApId, actor.ap_id),
647
+ with: { object: true },
648
+ orderBy: [desc(bookmarks.createdAt), desc(bookmarks.objectApId)],
649
+ limit: limit + 1,
650
+ });
651
+ const hasMore = scanned.length > limit;
652
+ const allBookmarkRows = hasMore ? scanned.slice(0, limit) : scanned;
653
+ const lastScanned = allBookmarkRows[allBookmarkRows.length - 1];
654
+ const nextCursor =
655
+ hasMore && lastScanned
656
+ ? encodeFeedCursor(lastScanned.createdAt, lastScanned.objectApId)
657
+ : null;
658
+
659
+ // Re-check read-access at read time so a bookmark can never resurface a post
660
+ // the viewer can no longer read. The per-post visibility decision is delegated
661
+ // to the canonical `passesPostVisibilitySync` (the SAME predicate the
662
+ // single-object gate uses) so the to/cc explicit-recipient and Story branches
663
+ // cannot drift here — the hand-rolled gate this replaces dropped the cc check
664
+ // AND had no Story reach/expiry branch, leaking a personal (followers-only)
665
+ // Story's caption/overlay/attachment metadata to a non-follower and past
666
+ // expiry (#3). The private-community membership gate is applied separately by
667
+ // the batched `communityReadableApIds` below.
668
+ //
669
+ // Batch the follower lookup: an accepted follow could matter for a
670
+ // followers-only post OR a personal (non-community) story, so collect those
671
+ // authors and resolve them in one query, then feed the Set to the predicate.
672
+ const followerGateAuthors = [
673
+ ...new Set(
674
+ allBookmarkRows
675
+ .filter(
676
+ (b) =>
677
+ b.object.attributedTo !== actor.ap_id &&
678
+ ((b.object.type === "Story" && !b.object.communityApId) ||
679
+ (b.object.type !== "Story" &&
680
+ b.object.visibility === "followers")),
681
+ )
682
+ .map((b) => b.object.attributedTo),
683
+ ),
684
+ ];
685
+ const followedAuthors =
686
+ followerGateAuthors.length > 0
687
+ ? new Set(
688
+ (
689
+ await db
690
+ .select({ followingApId: follows.followingApId })
691
+ .from(follows)
692
+ .where(
693
+ and(
694
+ eq(follows.followerApId, actor.ap_id),
695
+ inArray(follows.followingApId, followerGateAuthors),
696
+ eq(follows.status, "accepted"),
697
+ ),
698
+ )
699
+ ).map((r) => r.followingApId),
700
+ )
701
+ : new Set<string>();
702
+
703
+ // Sync visibility-gate first, then ONE batched community read-gate over the
704
+ // survivors (2 queries instead of 1-2 per bookmarked post).
705
+ const visibilityOk = allBookmarkRows.filter((b) =>
706
+ passesPostVisibilitySync(b.object, actor.ap_id, (a) =>
707
+ followedAuthors.has(a),
708
+ ),
709
+ );
710
+ const communityReadable = await communityReadableApIds(
711
+ db,
712
+ visibilityOk.map((b) => b.object),
713
+ actor.ap_id,
714
+ );
715
+ const bookmarkRows = visibilityOk.filter((b) =>
716
+ communityReadable.has(b.object.apId),
717
+ );
718
+
719
+ // Batch-load author info to avoid N+1 queries
720
+ const authorApIds = [
721
+ ...new Set(bookmarkRows.map((b) => b.object.attributedTo)),
722
+ ];
723
+ const [localActors, cachedActors] = await Promise.all([
724
+ db
725
+ .select({
726
+ apId: actors.apId,
727
+ preferredUsername: actors.preferredUsername,
728
+ name: actors.name,
729
+ iconUrl: actors.iconUrl,
730
+ })
731
+ .from(actors)
732
+ .where(inArray(actors.apId, authorApIds)),
733
+ db
734
+ .select({
735
+ apId: actorCache.apId,
736
+ preferredUsername: actorCache.preferredUsername,
737
+ name: actorCache.name,
738
+ iconUrl: actorCache.iconUrl,
739
+ })
740
+ .from(actorCache)
741
+ .where(inArray(actorCache.apId, authorApIds)),
742
+ ]);
743
+
744
+ const actorMap = new Map([
745
+ ...cachedActors.map((a) => [a.apId, a] as const),
746
+ ...localActors.map((a) => [a.apId, a] as const),
747
+ ]);
748
+
749
+ // Batch-load likes for all bookmarked posts
750
+ const postApIds = bookmarkRows.map((b) => b.object.apId);
751
+ const likeRows = await db
752
+ .select({ objectApId: likes.objectApId })
753
+ .from(likes)
754
+ .where(
755
+ and(
756
+ eq(likes.actorApId, actor.ap_id),
757
+ inArray(likes.objectApId, postApIds),
758
+ ),
759
+ );
760
+ const likedPostIds = new Set(likeRows.map((l) => l.objectApId));
761
+
762
+ const result = bookmarkRows.map((b) => {
763
+ const obj = b.object;
764
+ const authorInfo = actorMap.get(obj.attributedTo);
765
+
766
+ return {
767
+ ap_id: obj.apId,
768
+ type: obj.type,
769
+ author: {
770
+ ap_id: obj.attributedTo,
771
+ username: formatUsername(obj.attributedTo),
772
+ preferred_username: authorInfo?.preferredUsername ?? null,
773
+ name: authorInfo?.name ?? null,
774
+ icon_url: authorInfo?.iconUrl ?? null,
775
+ },
776
+ content: obj.content,
777
+ summary: obj.summary,
778
+ attachments: safeJsonParse(obj.attachmentsJson, []),
779
+ in_reply_to: obj.inReplyTo,
780
+ visibility: obj.visibility,
781
+ community_ap_id: obj.communityApId,
782
+ like_count: obj.likeCount,
783
+ reply_count: obj.replyCount,
784
+ announce_count: obj.announceCount,
785
+ published: obj.published,
786
+ liked: likedPostIds.has(obj.apId),
787
+ bookmarked: true,
788
+ reposted: false,
789
+ };
790
+ });
791
+
792
+ return c.json({ posts: result, has_more: hasMore, next_cursor: nextCursor });
793
+ });
794
+
795
+ export default posts;