@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,865 @@
1
+ import { Hono } from "hono";
2
+ import { actors, follows, objects } from "../../../db/index.ts";
3
+ import type { Database } from "../../../db/index.ts";
4
+ import { OBJECT_CONTEXT } from "../../lib/ap-context.ts";
5
+ import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
6
+ import type { Actor, Env, Variables } from "../../types.ts";
7
+ import {
8
+ activityApId,
9
+ formatUsername,
10
+ generateId,
11
+ isLocal,
12
+ isSafeRemoteUrl,
13
+ objectApId,
14
+ parseLimit,
15
+ safeJsonParse,
16
+ } from "../../federation-helpers.ts";
17
+ import {
18
+ formatPost,
19
+ MAX_POSTS_PAGE_LIMIT,
20
+ normalizeVisibility,
21
+ PostRow,
22
+ } from "./transformers.ts";
23
+ import {
24
+ AUTHOR_WITH,
25
+ buildAddressing,
26
+ buildCommunityObjectAddressing,
27
+ loadCachedAuthorMap,
28
+ loadInteractionFlags,
29
+ mergeCc,
30
+ persistActivity,
31
+ persistAndFanout,
32
+ persistAndFanoutToCommunity,
33
+ type PostDetailRow,
34
+ postWhereByIdOrApId,
35
+ type PostWithAuthor,
36
+ resolveAuthor,
37
+ resolveAuthorWithCache,
38
+ toPostRow,
39
+ } from "./queries.ts";
40
+ import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
41
+ import { deleteObjectCascade, purgeMediaBlobs } from "./delete-cascade.ts";
42
+ import {
43
+ checkCommunityPostPermission,
44
+ deriveContentTags,
45
+ insertPostAndHandleReply,
46
+ processMentions,
47
+ REPLY_TARGET_NOT_FOUND,
48
+ validateContentEdit,
49
+ validateCreatePostBody,
50
+ validateEditBody,
51
+ validateSummaryEdit,
52
+ } from "./post-helpers.ts";
53
+ import { requireActor } from "../actors-helpers.ts";
54
+ import { communityReadableApIds } from "../../lib/community-visibility.ts";
55
+ import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
56
+ import {
57
+ actorIsBlockedBy,
58
+ canViewerReadObjectFull,
59
+ } from "../../lib/post-visibility.ts";
60
+ import { toApAttachments } from "../../lib/activitypub-helpers.ts";
61
+ import { logger } from "../../lib/logger.ts";
62
+
63
+ const log = logger.child({ component: "posts.routes" });
64
+
65
+ // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
66
+ // union; reach it through a narrow structural cast (matching the other routes).
67
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
68
+
69
+ const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
70
+
71
+ const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
72
+
73
+ /** Reply row shape needed for the visibility gate (subset of the object row). */
74
+ type ReplyVisibilityRow = {
75
+ apId: string;
76
+ attributedTo: string;
77
+ visibility: string;
78
+ toJson?: string | null;
79
+ audienceJson?: string | null;
80
+ communityApId?: string | null;
81
+ };
82
+
83
+ /**
84
+ * Apply the SAME per-post visibility gate that GET /:id uses, to a LIST of
85
+ * replies, so a follower-only or direct reply is never returned to a viewer
86
+ * who is not its author / an accepted follower / an addressed recipient.
87
+ *
88
+ * `public` and `unlisted` replies are always visible. The accepted-follow
89
+ * edges the viewer needs across all follower-only reply authors are resolved
90
+ * in a single batched query to avoid an N+1.
91
+ */
92
+ async function filterVisibleReplies<T extends ReplyVisibilityRow>(
93
+ db: Database,
94
+ currentActor: Actor | null | undefined,
95
+ replies: T[],
96
+ ): Promise<T[]> {
97
+ const viewerApId = currentActor?.ap_id;
98
+
99
+ // Authors of follower-only replies the viewer does not own — these are the
100
+ // only authors we need an accepted-follow edge for.
101
+ const followerGateAuthors = new Set<string>();
102
+ for (const reply of replies) {
103
+ if (reply.visibility === "followers" && reply.attributedTo !== viewerApId) {
104
+ followerGateAuthors.add(reply.attributedTo);
105
+ }
106
+ }
107
+
108
+ let acceptedFollowing = new Set<string>();
109
+ if (viewerApId && followerGateAuthors.size > 0) {
110
+ const rows = await db
111
+ .select({ followingApId: follows.followingApId })
112
+ .from(follows)
113
+ .where(
114
+ and(
115
+ eq(follows.followerApId, viewerApId),
116
+ inArray(follows.followingApId, [...followerGateAuthors]),
117
+ eq(follows.status, "accepted"),
118
+ ),
119
+ );
120
+ acceptedFollowing = new Set(rows.map((r) => r.followingApId));
121
+ }
122
+
123
+ // Pre-compute the community read-gate for every reply: a community-scoped
124
+ // reply is stored "public" but carries an audience, so the per-visibility
125
+ // checks below would let it through. Resolving membership here (rather than
126
+ // inside the synchronous .filter) lets the predicate stay synchronous.
127
+ // Batched community read-gate for the whole page (2 queries, not 1-2 per
128
+ // reply). Same semantics as canViewerReadObject.
129
+ const communityReadable = await communityReadableApIds(
130
+ db,
131
+ replies,
132
+ viewerApId,
133
+ );
134
+
135
+ return replies.filter((reply) => {
136
+ // A private-community reply is hidden from anyone who is not an accepted
137
+ // member, regardless of the (stored "public") visibility.
138
+ if (!communityReadable.has(reply.apId)) return false;
139
+ if (reply.visibility === "followers") {
140
+ if (!viewerApId) return false;
141
+ if (reply.attributedTo === viewerApId) return true;
142
+ return acceptedFollowing.has(reply.attributedTo);
143
+ }
144
+ if (reply.visibility === "direct") {
145
+ if (!viewerApId) return false;
146
+ if (reply.attributedTo === viewerApId) return true;
147
+ const recipients = safeJsonParse<string[]>(reply.toJson, []);
148
+ return recipients.includes(viewerApId);
149
+ }
150
+ return true;
151
+ });
152
+ }
153
+
154
+ // --- Route handlers ---
155
+
156
+ // Create post
157
+ posts.post("/", async (c) => {
158
+ const actor = requireActor(c);
159
+ if (actor instanceof Response) return actor;
160
+
161
+ const validation = await validateCreatePostBody(c);
162
+ if (!validation.ok) {
163
+ return c.json(
164
+ {
165
+ error: validation.error,
166
+ ...(validation.code ? { code: validation.code } : {}),
167
+ },
168
+ 400,
169
+ );
170
+ }
171
+ const { body, content, summary } = validation;
172
+
173
+ const db = c.get("db");
174
+ const visibility = normalizeVisibility(body.visibility);
175
+
176
+ const communityCheck = await checkCommunityPostPermission(
177
+ db,
178
+ actor.ap_id,
179
+ body.community_ap_id,
180
+ );
181
+ if (!communityCheck.allowed) {
182
+ return c.json({ error: communityCheck.error }, communityCheck.status);
183
+ }
184
+ const communityId = communityCheck.communityId;
185
+ const community = communityCheck.community;
186
+
187
+ // Reply read-gate: a reply may only target a parent the replier can actually
188
+ // READ. Without this, anyone who learns a followers-only / direct /
189
+ // private-community post's apId could reply to it — inflating the author's
190
+ // replyCount, sending them a reply notification, and publishing a public reply
191
+ // whose inReplyTo discloses the restricted parent's existence (and bypassing a
192
+ // block). Mirror the like/repost gates; 404 to avoid leaking existence.
193
+ if (body.in_reply_to) {
194
+ const parent = await db
195
+ .select({
196
+ visibility: objects.visibility,
197
+ attributedTo: objects.attributedTo,
198
+ toJson: objects.toJson,
199
+ ccJson: objects.ccJson,
200
+ audienceJson: objects.audienceJson,
201
+ communityApId: objects.communityApId,
202
+ type: objects.type,
203
+ endTime: objects.endTime,
204
+ })
205
+ .from(objects)
206
+ .where(eq(objects.apId, body.in_reply_to))
207
+ .get();
208
+ if (
209
+ !parent ||
210
+ !(await canViewerReadObjectFull(db, parent, actor.ap_id)) ||
211
+ (await actorIsBlockedBy(db, parent.attributedTo, actor.ap_id))
212
+ ) {
213
+ return c.json({ error: "Post not found" }, 404);
214
+ }
215
+ }
216
+
217
+ const baseUrl = c.env.APP_URL;
218
+ const postId = generateId();
219
+ const apId = objectApId(baseUrl, postId);
220
+ const now = new Date().toISOString();
221
+
222
+ let parentAuthor: string | null = null;
223
+ try {
224
+ parentAuthor = await insertPostAndHandleReply(db, {
225
+ apId,
226
+ actorApId: actor.ap_id,
227
+ content,
228
+ summary: summary || null,
229
+ attachments: body.attachments,
230
+ inReplyTo: body.in_reply_to || null,
231
+ visibility,
232
+ communityId,
233
+ community,
234
+ baseUrl,
235
+ now,
236
+ });
237
+ } catch (e) {
238
+ if (e instanceof Error && e.message === REPLY_TARGET_NOT_FOUND) {
239
+ return c.json({ error: "Reply target not found" }, 404);
240
+ }
241
+ log.error("Failed to create post transaction", {
242
+ event: "posts.create.transaction_failed",
243
+ actor: actor.ap_id,
244
+ communityId,
245
+ error: e,
246
+ });
247
+ return c.json({ error: "Failed to create post" }, 500);
248
+ }
249
+
250
+ // Process mentions: resolve @mentions (local + remote), build `Mention`
251
+ // tags, create local notifications, and collect the resolved recipient IRIs.
252
+ const {
253
+ failures: mentionFailures,
254
+ tags: mentionTags,
255
+ mentionedActorApIds,
256
+ remoteMentionedActorApIds,
257
+ } = await processMentions(db, {
258
+ content,
259
+ postApId: apId,
260
+ actorApId: actor.ap_id,
261
+ parentAuthor,
262
+ baseUrl,
263
+ now,
264
+ });
265
+
266
+ // A reply must reach the post it replies to. processMentions only addresses
267
+ // actors EXPLICITLY @-mentioned in the body, so a reply to a remote post that
268
+ // doesn't manually @-mention its author was delivered to the replier's own
269
+ // followers but NEVER to the upstream instance — it never landed in the
270
+ // original thread (whereas Like/Undo-repost already reach the remote object
271
+ // author). Auto-address the parent author of a NON-direct reply: add it to cc
272
+ // and a `Mention` tag (so the reply threads + notifies on the receiving
273
+ // server) and, when remote, deliver the Create to its inbox. Direct replies
274
+ // keep mentions-only addressing (no implicit parent disclosure). The local
275
+ // parent author is already notified by the reply path, so this only augments
276
+ // addressing/delivery, never a duplicate local notification.
277
+ const replyRecipients = [...mentionedActorApIds];
278
+ const replyRemoteRecipients = [...remoteMentionedActorApIds];
279
+ const replyTags = [...mentionTags];
280
+ if (
281
+ body.in_reply_to &&
282
+ parentAuthor &&
283
+ parentAuthor !== actor.ap_id &&
284
+ visibility !== "direct" &&
285
+ !replyRecipients.includes(parentAuthor)
286
+ ) {
287
+ replyRecipients.push(parentAuthor);
288
+ replyTags.push({
289
+ type: "Mention",
290
+ href: parentAuthor,
291
+ name: `@${formatUsername(parentAuthor)}`,
292
+ });
293
+ if (!isLocal(parentAuthor, baseUrl)) {
294
+ replyRemoteRecipients.push(parentAuthor);
295
+ }
296
+ }
297
+
298
+ // Federate when the post has follower/public/community reach OR when it has
299
+ // resolved mentions (a mention is an explicit recipient, so even a "direct"
300
+ // post must federate the Create to its remote mentioned actors).
301
+ //
302
+ // Community-scoped posts have reach == community: address the Create toward
303
+ // the community Group actor + its followers collection (NOT the author's
304
+ // personal followers), record the community in `audience`, and fan out to
305
+ // the community's members/followers. Non-community posts keep the existing
306
+ // author-follower addressing and fan-out. Mentioned actors are always added
307
+ // to `cc` so the post is addressed to them on the receiving server.
308
+ if (visibility !== "direct" || mentionedActorApIds.length > 0) {
309
+ let to: string[];
310
+ let cc: string[];
311
+ let audience: string[] | undefined;
312
+
313
+ if (visibility === "direct") {
314
+ // Direct post with mentions: no follower/public reach, only the
315
+ // mentioned actors are recipients.
316
+ to = [];
317
+ cc = [];
318
+ } else if (community) {
319
+ const objectAddressing = buildCommunityObjectAddressing(
320
+ visibility,
321
+ community,
322
+ );
323
+ to = objectAddressing.to;
324
+ cc = objectAddressing.cc;
325
+ audience = objectAddressing.audience;
326
+ } else {
327
+ const followersUrl = `${actor.ap_id}/followers`;
328
+ ({ to, cc } = buildAddressing(visibility, followersUrl));
329
+ }
330
+
331
+ // Add every mentioned actor IRI — plus an auto-addressed reply parent — to
332
+ // cc (de-duplicated).
333
+ cc = mergeCc(cc, replyRecipients);
334
+
335
+ const tag = replyTags.length > 0 ? replyTags : undefined;
336
+
337
+ const createActivity = {
338
+ "@context": OBJECT_CONTEXT,
339
+ id: activityApId(baseUrl, generateId()),
340
+ type: "Create",
341
+ actor: actor.ap_id,
342
+ published: now,
343
+ to,
344
+ cc,
345
+ ...(audience ? { audience } : {}),
346
+ ...(tag ? { tag } : {}),
347
+ object: {
348
+ "@context": OBJECT_CONTEXT,
349
+ id: apId,
350
+ type: "Note",
351
+ attributedTo: actor.ap_id,
352
+ content,
353
+ summary: summary || null,
354
+ // A non-empty summary is a content warning; Mastodon-compatible peers
355
+ // gate rendering on BOTH `summary` (the CW text) and `sensitive`. The
356
+ // served object doc (routes/activitypub/outbox.ts) already sets this, so
357
+ // the delivered Create must match or the CW federates inconsistently.
358
+ ...(summary ? { sensitive: true } : {}),
359
+ // Media is stored as an app-relative /media path; absolutize for the
360
+ // federated copy so remote servers can fetch the image.
361
+ attachment: toApAttachments(body.attachments || [], baseUrl),
362
+ inReplyTo: body.in_reply_to || null,
363
+ published: now,
364
+ to,
365
+ cc,
366
+ ...(audience ? { audience } : {}),
367
+ ...(tag ? { tag } : {}),
368
+ },
369
+ };
370
+
371
+ if (visibility === "direct") {
372
+ // No follower/community fanout for a direct post — persist only, then
373
+ // direct-deliver to the remote mentioned actors below.
374
+ await persistActivity(db, createActivity, apId);
375
+ } else if (community) {
376
+ await persistAndFanoutToCommunity(
377
+ db,
378
+ c.env,
379
+ createActivity,
380
+ apId,
381
+ community.apId,
382
+ );
383
+ } else {
384
+ await persistAndFanout(db, c.env, createActivity, apId);
385
+ }
386
+
387
+ // Deliver the Create directly to each remote mentioned actor's inbox — plus
388
+ // the remote parent author of a reply (auto-addressed above). Community/
389
+ // follower fanout does not include arbitrary remote actors, so this is the
390
+ // only path that reaches a remote @user@domain mention or reply target.
391
+ for (const recipient of replyRemoteRecipients) {
392
+ try {
393
+ await enqueueDeliveryToActor(c.env, createActivity.id, recipient);
394
+ } catch (err) {
395
+ log.error("Failed to enqueue mention delivery", {
396
+ event: "posts.mention.delivery_enqueue_failed",
397
+ activityId: createActivity.id,
398
+ recipient,
399
+ error: err,
400
+ });
401
+ }
402
+ }
403
+ }
404
+
405
+ const createdPost = {
406
+ ap_id: apId,
407
+ type: "Note",
408
+ author: {
409
+ ap_id: actor.ap_id,
410
+ username: formatUsername(actor.ap_id),
411
+ preferred_username: actor.preferred_username,
412
+ name: actor.name,
413
+ icon_url: actor.icon_url,
414
+ },
415
+ content,
416
+ summary: summary || null,
417
+ attachments: body.attachments || [],
418
+ visibility,
419
+ published: now,
420
+ like_count: 0,
421
+ reply_count: 0,
422
+ announce_count: 0,
423
+ liked: false,
424
+ bookmarked: false,
425
+ ...(mentionFailures.length > 0
426
+ ? {
427
+ mention_processing: {
428
+ failed_count: mentionFailures.length,
429
+ failures: mentionFailures,
430
+ },
431
+ }
432
+ : {}),
433
+ };
434
+
435
+ return c.json({
436
+ ...createdPost,
437
+ post: createdPost,
438
+ });
439
+ });
440
+
441
+ // Get single post
442
+ posts.get("/:id", async (c) => {
443
+ const currentActor = c.get("actor");
444
+ const postId = c.req.param("id");
445
+ const baseUrl = c.env.APP_URL;
446
+ const db = c.get("db");
447
+
448
+ const post = await db.query.objects.findFirst({
449
+ where: postWhereByIdOrApId(baseUrl, postId),
450
+ with: AUTHOR_WITH,
451
+ });
452
+
453
+ if (!post) return c.json({ error: "Post not found" }, 404);
454
+
455
+ // Resolve author and interaction flags in parallel
456
+ const [author, { likedIds, bookmarkedIds }] = await Promise.all([
457
+ resolveAuthorWithCache(post.author, post.attributedTo, db),
458
+ loadInteractionFlags(db, currentActor?.ap_id, [post.apId]),
459
+ ]);
460
+ const liked = likedIds.has(post.apId);
461
+ const bookmarked = bookmarkedIds.has(post.apId);
462
+
463
+ // Single canonical read-gate: community membership + per-post visibility
464
+ // (public / unlisted / followers / direct, honoring an explicit to/cc mention)
465
+ // + the Story reach rule (a Story is stored "public" / empty-audience but is
466
+ // followers-/member-only and is revoked at endTime — without the Story branch
467
+ // its full caption/poll/media payload leaked here to any caller with the apId).
468
+ // `post` (a full objects row) carries type + endTime so the Story branch fires.
469
+ if (!(await canViewerReadObjectFull(db, post, currentActor?.ap_id))) {
470
+ return c.json({ error: "Post not found" }, 404);
471
+ }
472
+
473
+ const postRow: PostDetailRow = toPostRow(post, author, { liked, bookmarked });
474
+
475
+ return c.json({ post: formatPost(postRow, currentActor?.ap_id) });
476
+ });
477
+
478
+ // Get post replies
479
+ posts.get("/:id/replies", async (c) => {
480
+ const currentActor = c.get("actor");
481
+ const postId = c.req.param("id");
482
+ const baseUrl = c.env.APP_URL;
483
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_POSTS_PAGE_LIMIT);
484
+ const before = c.req.query("before");
485
+ const db = c.get("db");
486
+
487
+ const parentPost = await db
488
+ .select({
489
+ apId: objects.apId,
490
+ visibility: objects.visibility,
491
+ attributedTo: objects.attributedTo,
492
+ toJson: objects.toJson,
493
+ ccJson: objects.ccJson,
494
+ audienceJson: objects.audienceJson,
495
+ communityApId: objects.communityApId,
496
+ type: objects.type,
497
+ endTime: objects.endTime,
498
+ })
499
+ .from(objects)
500
+ .where(postWhereByIdOrApId(baseUrl, postId)!)
501
+ .get();
502
+
503
+ if (!parentPost) return c.json({ error: "Post not found" }, 404);
504
+
505
+ // Gate the parent with the FULL read-gate (community membership AND the
506
+ // followers/direct per-post visibility), mirroring GET /:id and GET
507
+ // /ap/objects/:id. Gating only the community dimension let anyone enumerate a
508
+ // followers-only / direct parent's public replies and confirm the restricted
509
+ // parent exists — an existence/metadata oracle the other surfaces deny.
510
+ if (!(await canViewerReadObjectFull(db, parentPost, currentActor?.ap_id))) {
511
+ return c.json({ error: "Post not found" }, 404);
512
+ }
513
+
514
+ // Composite (published, apId) cursor so replies sharing a published
515
+ // millisecond aren't skipped at a page boundary (see lib/feed-cursor.ts).
516
+ const cursorPredicate = feedCursorWhere(
517
+ objects.published,
518
+ objects.apId,
519
+ before,
520
+ );
521
+
522
+ // Fetch limit+1 to compute has_more, then SLICE before the per-reply
523
+ // visibility filter. Advancing the cursor by the last SCANNED row (not the
524
+ // last readable one) means unreadable replies are skipped without ever
525
+ // skipping a readable one — so load-more reaches every readable reply, and
526
+ // the gate dropping rows can only make a page short, never lose a reply.
527
+ const scanned = await db.query.objects.findMany({
528
+ where: cursorPredicate
529
+ ? and(eq(objects.inReplyTo, parentPost.apId), cursorPredicate)
530
+ : eq(objects.inReplyTo, parentPost.apId),
531
+ with: AUTHOR_WITH,
532
+ orderBy: [desc(objects.published), desc(objects.apId)],
533
+ limit: limit + 1,
534
+ });
535
+ const hasMore = scanned.length > limit;
536
+ const page = hasMore ? scanned.slice(0, limit) : scanned;
537
+ const lastScanned = page[page.length - 1];
538
+ const nextCursor =
539
+ hasMore && lastScanned
540
+ ? encodeFeedCursor(lastScanned.published, lastScanned.apId)
541
+ : null;
542
+
543
+ // Apply the SAME visibility gate as GET /:id, per-reply: a follower-only or
544
+ // direct reply must not leak to a viewer who is not its author / an accepted
545
+ // follower / an addressed recipient. Resolve the accepted-follow edges the
546
+ // viewer needs in a single batched query to avoid an N+1.
547
+ const replies = await filterVisibleReplies(db, currentActor, page);
548
+
549
+ // Batch load cached authors and interaction flags in parallel
550
+ const replyApIds = replies.map((r) => r.apId);
551
+ const [cachedAuthorMap, { likedIds }] = await Promise.all([
552
+ loadCachedAuthorMap(db, replies as PostWithAuthor[]),
553
+ loadInteractionFlags(db, currentActor?.ap_id, replyApIds),
554
+ ]);
555
+
556
+ const result = replies.map((reply) => {
557
+ const author = resolveAuthor(
558
+ reply.author,
559
+ reply.attributedTo,
560
+ cachedAuthorMap,
561
+ );
562
+ const postRow = toPostRow(reply as PostWithAuthor, author, {
563
+ liked: likedIds.has(reply.apId),
564
+ });
565
+ return formatPost(postRow, currentActor?.ap_id);
566
+ });
567
+
568
+ return c.json({
569
+ replies: result,
570
+ has_more: hasMore,
571
+ next_cursor: nextCursor,
572
+ });
573
+ });
574
+
575
+ // Edit post
576
+ posts.patch("/:id", async (c) => {
577
+ const actor = requireActor(c);
578
+ if (actor instanceof Response) return actor;
579
+
580
+ const postId = c.req.param("id");
581
+ const baseUrl = c.env.APP_URL;
582
+
583
+ const editValidation = await validateEditBody(c);
584
+ if (!editValidation.ok) {
585
+ return c.json(
586
+ {
587
+ error: editValidation.error,
588
+ ...(editValidation.code ? { code: editValidation.code } : {}),
589
+ },
590
+ 400,
591
+ );
592
+ }
593
+ const { body } = editValidation;
594
+
595
+ const db = c.get("db");
596
+
597
+ const post = await db.query.objects.findFirst({
598
+ where: postWhereByIdOrApId(baseUrl, postId),
599
+ });
600
+ if (!post) return c.json({ error: "Post not found" }, 404);
601
+ if (post.attributedTo !== actor.ap_id) {
602
+ return c.json({ error: "Forbidden" }, 403);
603
+ }
604
+
605
+ // Validate content
606
+ const contentCheck = validateContentEdit(body.content);
607
+ if (!contentCheck.ok) return c.json({ error: contentCheck.error }, 400);
608
+ const trimmedContent = contentCheck.ok ? contentCheck.trimmed : undefined;
609
+
610
+ // Validate summary
611
+ const summaryCheck = validateSummaryEdit(body.summary);
612
+ if (!summaryCheck.ok) return c.json({ error: summaryCheck.error }, 400);
613
+ const trimmedSummary = summaryCheck.ok ? summaryCheck.trimmed : undefined;
614
+
615
+ const nextContent =
616
+ body.content !== undefined ? (trimmedContent as string) : post.content;
617
+ const nextSummary =
618
+ body.summary !== undefined ? trimmedSummary || null : post.summary;
619
+ const now = new Date().toISOString();
620
+
621
+ const updateData: {
622
+ content?: string;
623
+ summary?: string | null;
624
+ tagsJson?: string;
625
+ updated: string;
626
+ } = { updated: now };
627
+
628
+ if (body.content !== undefined) updateData.content = trimmedContent;
629
+ if (body.summary !== undefined) updateData.summary = trimmedSummary || null;
630
+
631
+ if (Object.keys(updateData).length === 1) {
632
+ return c.json({ error: "No changes provided" }, 400);
633
+ }
634
+
635
+ // Re-derive the post's AS2 tags (Hashtag + Mention) from the next content so
636
+ // the served object doc and the Update(Note) below carry the same tags a
637
+ // fresh post would — otherwise editing a #hashtag / @mention post would strip
638
+ // those tags from remote copies. Side-effect-free (no re-notification);
639
+ // persist tagsJson only when the content actually changed.
640
+ const nextTags = await deriveContentTags(
641
+ db,
642
+ nextContent,
643
+ baseUrl,
644
+ actor.ap_id,
645
+ );
646
+ if (body.content !== undefined) {
647
+ updateData.tagsJson = JSON.stringify(nextTags);
648
+ }
649
+
650
+ await db.update(objects).set(updateData).where(eq(objects.apId, post.apId));
651
+
652
+ // Mirror the stored post's addressing onto the Update so its audience matches
653
+ // the ORIGINAL post (like the Delete path). Without this the Update carried no
654
+ // to/cc/audience and — combined with the community branch below — fanned out
655
+ // to the wrong graph.
656
+ const updateTo = safeJsonParse<string[]>(post.toJson, []);
657
+ const updateCc = safeJsonParse<string[]>(post.ccJson, []);
658
+ const updateAudience = safeJsonParse<string[]>(post.audienceJson, []);
659
+
660
+ const updateActivity = {
661
+ "@context": "https://www.w3.org/ns/activitystreams",
662
+ id: activityApId(baseUrl, generateId()),
663
+ type: "Update",
664
+ actor: actor.ap_id,
665
+ to: updateTo,
666
+ cc: updateCc,
667
+ ...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
668
+ object: {
669
+ id: post.apId,
670
+ type: "Note",
671
+ attributedTo: actor.ap_id,
672
+ content: nextContent,
673
+ summary: nextSummary,
674
+ // Keep the CW's `sensitive` flag in sync on edit. Unlike the create path
675
+ // this is always a boolean (not omitted) so that REMOVING a content
676
+ // warning pushes `sensitive: false` and clears it on followers who act on
677
+ // the Update without re-fetching the object.
678
+ sensitive: Boolean(nextSummary),
679
+ // Carry the re-derived tags so a receiver updating the Note keeps its
680
+ // Hashtag/Mention tags instead of dropping them on edit.
681
+ ...(nextTags.length > 0 ? { tag: nextTags } : {}),
682
+ to: updateTo,
683
+ cc: updateCc,
684
+ ...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
685
+ updated: now,
686
+ },
687
+ };
688
+
689
+ // A community-scoped post's Update must reach the COMMUNITY (the members who
690
+ // got the Create), NOT the author's personal followers — who never received
691
+ // the Create. Mirror the create path's community-vs-personal fan-out branch.
692
+ if (post.communityApId) {
693
+ await persistAndFanoutToCommunity(
694
+ db,
695
+ c.env,
696
+ updateActivity,
697
+ post.apId,
698
+ post.communityApId,
699
+ );
700
+ } else {
701
+ await persistAndFanout(db, c.env, updateActivity, post.apId);
702
+ }
703
+
704
+ return c.json({
705
+ success: true,
706
+ post: {
707
+ ap_id: post.apId,
708
+ content: nextContent,
709
+ summary: nextSummary,
710
+ updated_at: now,
711
+ },
712
+ });
713
+ });
714
+
715
+ // Delete post
716
+ posts.delete("/:id", async (c) => {
717
+ const actor = requireActor(c);
718
+ if (actor instanceof Response) return actor;
719
+
720
+ const postId = c.req.param("id");
721
+ const baseUrl = c.env.APP_URL;
722
+ const db = c.get("db");
723
+
724
+ const post = await db.query.objects.findFirst({
725
+ where: postWhereByIdOrApId(baseUrl, postId),
726
+ });
727
+
728
+ if (!post) return c.json({ error: "Post not found" }, 404);
729
+ if (post.attributedTo !== actor.ap_id) {
730
+ return c.json({ error: "Forbidden" }, 403);
731
+ }
732
+
733
+ // D1 doesn't support interactive transactions; use sequential operations.
734
+ // FK ON DELETE CASCADE is not reliably enforced (PRAGMA foreign_keys is not
735
+ // guaranteed on every runtime/connection, and D1 ignores it), so delete the
736
+ // object's child rows explicitly before the object row to avoid orphans.
737
+ const mediaKeys = await deleteObjectCascade(db, post.apId, c.env.MEDIA);
738
+
739
+ // Co-commit the object delete + author postCount-- + parent replyCount in ONE
740
+ // batch (mirrors the federated handleDelete): a crash between separate
741
+ // autocommits would otherwise leave the row gone with an un-decremented
742
+ // postCount (permanent over-count, no recovery). postCount-- is guarded by
743
+ // gt>0 (underflow) + EXISTS(object) so it fires exactly once; the parent
744
+ // replyCount is RECOMPUTED from COUNT(*) after the delete — exact + idempotent.
745
+ const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${post.apId})`;
746
+ const decPostCount = db
747
+ .update(actors)
748
+ .set({ postCount: sql`${actors.postCount} - 1` })
749
+ .where(
750
+ and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0), objectExists),
751
+ );
752
+ const deleteObject = db.delete(objects).where(eq(objects.apId, post.apId));
753
+ // DM notes (`visibility="direct"`, created by createDmNote) are NOT counted in
754
+ // postCount on send, so deleting one here must NOT decrement it — otherwise a
755
+ // DM deleted through this generic endpoint (the dedicated DELETE
756
+ // /dm/messages/:id correctly skips the count) under-counts the author's
757
+ // postCount (floored at 0). Keep create/delete symmetric: only regular posts
758
+ // (which incremented) decrement.
759
+ const ops: unknown[] = [];
760
+ if (post.visibility !== "direct") ops.push(decPostCount);
761
+ ops.push(deleteObject);
762
+ if (post.inReplyTo) {
763
+ const parentId = post.inReplyTo;
764
+ ops.push(
765
+ db
766
+ .update(objects)
767
+ .set({
768
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
769
+ })
770
+ .where(eq(objects.apId, parentId)),
771
+ );
772
+ }
773
+ await (db as unknown as Batchable).batch(ops);
774
+
775
+ // Irreversible R2 purge LAST — only now that the objects row is gone. A
776
+ // failure here degrades to a leaked blob, not a live post with a deleted blob.
777
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
778
+
779
+ // The Delete must reach everyone the original object reached, not just the
780
+ // author's current followers: mirror the object's stored to/cc, and emit a
781
+ // Tombstone object (per AP) instead of a bare IRI so receivers can render
782
+ // the deletion correctly.
783
+ const originalTo = safeJsonParse<string[]>(post.toJson, []);
784
+ const originalCc = safeJsonParse<string[]>(post.ccJson, []);
785
+
786
+ // For a reply, the parent author's instance must also be told (it counts the
787
+ // reply); for a direct post, the DM recipients are exactly the addressed
788
+ // actors. Collect explicit (actor-IRI) recipients for direct per-actor
789
+ // delivery — anything that is not a Public/collection IRI is treated as an
790
+ // actor inbox target if it is a safe remote URL.
791
+ const explicitRecipients = new Set<string>();
792
+ for (const iri of [...originalTo, ...originalCc]) {
793
+ if (
794
+ iri &&
795
+ iri !== PUBLIC_COLLECTION &&
796
+ !iri.endsWith("/followers") &&
797
+ isLocal(iri, baseUrl) === false &&
798
+ isSafeRemoteUrl(iri)
799
+ ) {
800
+ explicitRecipients.add(iri);
801
+ }
802
+ }
803
+
804
+ // Parent author's instance (replies): ensure the reply deletion propagates
805
+ // to the thread root's host even if it was not in the object's to/cc.
806
+ if (post.inReplyTo) {
807
+ const parent = await db
808
+ .select({ attributedTo: objects.attributedTo })
809
+ .from(objects)
810
+ .where(eq(objects.apId, post.inReplyTo))
811
+ .get();
812
+ if (
813
+ parent?.attributedTo &&
814
+ !isLocal(parent.attributedTo, baseUrl) &&
815
+ isSafeRemoteUrl(parent.attributedTo)
816
+ ) {
817
+ explicitRecipients.add(parent.attributedTo);
818
+ }
819
+ }
820
+
821
+ const deleteActivity = {
822
+ "@context": "https://www.w3.org/ns/activitystreams",
823
+ id: activityApId(baseUrl, generateId()),
824
+ type: "Delete",
825
+ actor: actor.ap_id,
826
+ to: originalTo,
827
+ cc: originalCc,
828
+ object: {
829
+ id: post.apId,
830
+ type: "Tombstone",
831
+ },
832
+ };
833
+
834
+ // Fan out matching the original create reach (community → the community, not
835
+ // the author's personal followers) and additionally deliver directly to each
836
+ // explicitly-addressed remote actor.
837
+ if (post.communityApId) {
838
+ await persistAndFanoutToCommunity(
839
+ db,
840
+ c.env,
841
+ deleteActivity,
842
+ post.apId,
843
+ post.communityApId,
844
+ );
845
+ } else {
846
+ await persistAndFanout(db, c.env, deleteActivity, post.apId);
847
+ }
848
+
849
+ for (const recipient of explicitRecipients) {
850
+ try {
851
+ await enqueueDeliveryToActor(c.env, deleteActivity.id, recipient);
852
+ } catch (err) {
853
+ log.error("Failed to enqueue delete delivery", {
854
+ event: "posts.delete.delivery_enqueue_failed",
855
+ activityId: deleteActivity.id,
856
+ recipient,
857
+ error: err,
858
+ });
859
+ }
860
+ }
861
+
862
+ return c.json({ success: true });
863
+ });
864
+
865
+ export default posts;