@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,847 @@
1
+ /**
2
+ * Post route helper functions
3
+ *
4
+ * Extracted from base.ts to reduce file size. Contains:
5
+ * - validateCreatePostBody: full validation for POST / body
6
+ * - checkCommunityPostPermission: community policy enforcement
7
+ * - processMentions: mention extraction, resolution, and notification
8
+ * - validateEditFields: content/summary validation for PATCH
9
+ */
10
+
11
+ import {
12
+ activities,
13
+ actorCache,
14
+ actors,
15
+ communities,
16
+ communityMembers,
17
+ inbox as inboxTable,
18
+ objects,
19
+ } from "../../../db/index.ts";
20
+ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
21
+ import type { Database } from "../../../db/index.ts";
22
+ import type { Env } from "../../types.ts";
23
+ import {
24
+ activityApId,
25
+ formatUsername,
26
+ generateId,
27
+ isLocal,
28
+ } from "../../federation-helpers.ts";
29
+ import {
30
+ extractHashtags,
31
+ extractMentions,
32
+ MAX_ATTACHMENTS,
33
+ MAX_ATTACHMENTS_JSON_LENGTH,
34
+ MAX_POST_CONTENT_LENGTH,
35
+ MAX_POST_SUMMARY_LENGTH,
36
+ } from "./transformers.ts";
37
+ import {
38
+ buildCommunityObjectAddressing,
39
+ type CreatePostBody,
40
+ isRecord,
41
+ type MentionFailure,
42
+ type PostTag,
43
+ parseJsonObject,
44
+ type PostAttachment,
45
+ type ProcessMentionsResult,
46
+ validateOptionalString,
47
+ } from "./queries.ts";
48
+ import { logger } from "../../lib/logger.ts";
49
+ import { chunkForInClause } from "../../lib/chunk.ts";
50
+
51
+ const log = logger.child({ component: "posts.helpers" });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Validation helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export type CreatePostValidationResult =
58
+ | {
59
+ ok: true;
60
+ body: CreatePostBody;
61
+ content: string;
62
+ summary: string | undefined;
63
+ }
64
+ | { ok: false; error: string; code?: string };
65
+
66
+ /**
67
+ * Parse and validate the raw request body for creating a post.
68
+ * Returns a discriminated union: ok with parsed body, or error details.
69
+ */
70
+ export async function validateCreatePostBody(c: {
71
+ req: { json: () => Promise<unknown> };
72
+ }): Promise<CreatePostValidationResult> {
73
+ const rawBody = await parseJsonObject(c);
74
+ if (!rawBody) {
75
+ return { ok: false, error: "Invalid request body", code: "BAD_REQUEST" };
76
+ }
77
+
78
+ if (typeof rawBody.content !== "string") {
79
+ return {
80
+ ok: false,
81
+ error: "content must be a string",
82
+ code: "BAD_REQUEST",
83
+ };
84
+ }
85
+
86
+ for (const field of [
87
+ "summary",
88
+ "visibility",
89
+ "in_reply_to",
90
+ "community_ap_id",
91
+ ] as const) {
92
+ const err = validateOptionalString(rawBody, field);
93
+ if (err) return { ok: false, error: err, code: "BAD_REQUEST" };
94
+ }
95
+
96
+ if (
97
+ rawBody.attachments !== undefined &&
98
+ !Array.isArray(rawBody.attachments)
99
+ ) {
100
+ return {
101
+ ok: false,
102
+ error: "attachments must be an array",
103
+ code: "BAD_REQUEST",
104
+ };
105
+ }
106
+ if (
107
+ Array.isArray(rawBody.attachments) &&
108
+ rawBody.attachments.some((a) => !isRecord(a))
109
+ ) {
110
+ return {
111
+ ok: false,
112
+ error: "attachments must be objects",
113
+ code: "BAD_REQUEST",
114
+ };
115
+ }
116
+ // Bound the attachments payload (count + serialized size). content/summary are
117
+ // length-capped above; without this an attachments blob could carry up to the
118
+ // global 1 MiB body cap into the stored row and every federated delivery.
119
+ if (Array.isArray(rawBody.attachments)) {
120
+ if (rawBody.attachments.length > MAX_ATTACHMENTS) {
121
+ return {
122
+ ok: false,
123
+ error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
124
+ code: "BAD_REQUEST",
125
+ };
126
+ }
127
+ if (
128
+ JSON.stringify(rawBody.attachments).length > MAX_ATTACHMENTS_JSON_LENGTH
129
+ ) {
130
+ return {
131
+ ok: false,
132
+ error: "attachments payload too large",
133
+ code: "BAD_REQUEST",
134
+ };
135
+ }
136
+ }
137
+
138
+ const body: CreatePostBody = {
139
+ content: rawBody.content,
140
+ summary: typeof rawBody.summary === "string" ? rawBody.summary : undefined,
141
+ attachments: Array.isArray(rawBody.attachments)
142
+ ? (rawBody.attachments as PostAttachment[])
143
+ : undefined,
144
+ in_reply_to:
145
+ typeof rawBody.in_reply_to === "string" ? rawBody.in_reply_to : undefined,
146
+ visibility:
147
+ typeof rawBody.visibility === "string" ? rawBody.visibility : undefined,
148
+ community_ap_id:
149
+ typeof rawBody.community_ap_id === "string"
150
+ ? rawBody.community_ap_id
151
+ : undefined,
152
+ };
153
+
154
+ const content = body.content.trim();
155
+ const summary = body.summary?.trim();
156
+
157
+ if (!content) {
158
+ return { ok: false, error: "Content required" };
159
+ }
160
+ if (content.length > MAX_POST_CONTENT_LENGTH) {
161
+ return {
162
+ ok: false,
163
+ error: `Content too long (max ${MAX_POST_CONTENT_LENGTH} chars)`,
164
+ };
165
+ }
166
+ if (summary && summary.length > MAX_POST_SUMMARY_LENGTH) {
167
+ return {
168
+ ok: false,
169
+ error: `Summary too long (max ${MAX_POST_SUMMARY_LENGTH} chars)`,
170
+ };
171
+ }
172
+
173
+ return { ok: true, body, content, summary };
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Community policy check
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export type CommunityTarget = {
181
+ apId: string;
182
+ followersUrl: string;
183
+ };
184
+
185
+ export type CommunityCheckResult =
186
+ | {
187
+ allowed: true;
188
+ communityId: string | null;
189
+ community: CommunityTarget | null;
190
+ }
191
+ | { allowed: false; error: string; status: 403 | 404 };
192
+
193
+ /**
194
+ * Check whether the actor is allowed to post in the given community.
195
+ * If `communityApId` is undefined, returns { allowed: true, communityId: null }.
196
+ */
197
+ export async function checkCommunityPostPermission(
198
+ db: Database,
199
+ actorApId: string,
200
+ communityApId: string | undefined,
201
+ ): Promise<CommunityCheckResult> {
202
+ if (!communityApId) {
203
+ return { allowed: true, communityId: null, community: null };
204
+ }
205
+
206
+ const community = await db
207
+ .select({
208
+ apId: communities.apId,
209
+ followersUrl: communities.followersUrl,
210
+ postPolicy: communities.postPolicy,
211
+ visibility: communities.visibility,
212
+ })
213
+ .from(communities)
214
+ .where(
215
+ and(
216
+ or(
217
+ eq(communities.apId, communityApId),
218
+ eq(communities.preferredUsername, communityApId),
219
+ ),
220
+ isNull(communities.deletedAt),
221
+ ),
222
+ )
223
+ .get();
224
+
225
+ if (!community) {
226
+ return { allowed: false, error: "Community not found", status: 404 };
227
+ }
228
+
229
+ const membership = await db
230
+ .select({
231
+ role: communityMembers.role,
232
+ })
233
+ .from(communityMembers)
234
+ .where(
235
+ and(
236
+ eq(communityMembers.communityApId, community.apId),
237
+ eq(communityMembers.actorApId, actorApId),
238
+ ),
239
+ )
240
+ .get();
241
+
242
+ const policy = community.postPolicy || "members";
243
+ const role = membership?.role as "owner" | "moderator" | "member" | undefined;
244
+ const isManager = role === "owner" || role === "moderator";
245
+
246
+ // A non-public community requires membership to WRITE regardless of
247
+ // post_policy. Read access is membership-gated (canViewerReadObject /
248
+ // checkReadAccess), so without this a private community with
249
+ // post_policy="anyone" would let a non-member who CANNOT read it inject posts.
250
+ if ((community.visibility ?? "public") !== "public" && !membership) {
251
+ return { allowed: false, error: "Not a community member", status: 403 };
252
+ }
253
+ if (policy !== "anyone" && !membership) {
254
+ return { allowed: false, error: "Not a community member", status: 403 };
255
+ }
256
+ if (policy === "mods" && !isManager) {
257
+ return { allowed: false, error: "Moderator role required", status: 403 };
258
+ }
259
+ if (policy === "owners" && role !== "owner") {
260
+ return { allowed: false, error: "Owner role required", status: 403 };
261
+ }
262
+
263
+ return {
264
+ allowed: true,
265
+ communityId: community.apId,
266
+ community: {
267
+ apId: community.apId,
268
+ followersUrl: community.followersUrl,
269
+ },
270
+ };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Reply handling
275
+ // ---------------------------------------------------------------------------
276
+
277
+ export const REPLY_TARGET_NOT_FOUND = "REPLY_TARGET_NOT_FOUND";
278
+
279
+ // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
280
+ // union; reach it through a narrow structural cast (matching the other routes).
281
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
282
+
283
+ /**
284
+ * Insert the post object, increment author post count, and handle reply-chain
285
+ * updates (parent reply count bump + notification to local parent author).
286
+ *
287
+ * Throws Error(REPLY_TARGET_NOT_FOUND) if in_reply_to references a missing post.
288
+ * Returns the parentAuthor apId (or null if not a reply).
289
+ */
290
+ export async function insertPostAndHandleReply(
291
+ db: Database,
292
+ params: {
293
+ apId: string;
294
+ actorApId: string;
295
+ content: string;
296
+ summary: string | null;
297
+ attachments: PostAttachment[] | undefined;
298
+ inReplyTo: string | null;
299
+ visibility: string;
300
+ communityId: string | null;
301
+ community: CommunityTarget | null;
302
+ baseUrl: string;
303
+ now: string;
304
+ },
305
+ ): Promise<string | null> {
306
+ let parentAuthor: string | null = null;
307
+
308
+ // Community-scoped posts are ADDRESSED to the community Group actor: the
309
+ // community (and its followers collection) goes into to/audience. A non-"[]"
310
+ // audienceJson is exactly what excludes the post from the public/home feed,
311
+ // so reach is the community — not the open public timeline.
312
+ const addressing = buildCommunityObjectAddressing(
313
+ params.visibility,
314
+ params.community,
315
+ );
316
+
317
+ // Look up + validate the reply parent BEFORE the write batch — we need its
318
+ // author both as the replyCount target and for the reply notification.
319
+ if (params.inReplyTo) {
320
+ const parentPost = await db
321
+ .select({ attributedTo: objects.attributedTo })
322
+ .from(objects)
323
+ .where(eq(objects.apId, params.inReplyTo))
324
+ .get();
325
+ if (!parentPost) throw new Error(REPLY_TARGET_NOT_FOUND);
326
+ parentAuthor = parentPost.attributedTo;
327
+ }
328
+
329
+ // Co-commit the object insert + author postCount++ + parent replyCount recompute
330
+ // in ONE batch (mirrors the federated handleCreate): a crash between separate
331
+ // autocommits would otherwise leave the object inserted with an un-bumped
332
+ // postCount (permanent under-count). postCount++ is guarded NOT EXISTS(object)
333
+ // so a retry can't double-count; the parent replyCount is RECOMPUTED from
334
+ // COUNT(*) of the reply edge set — exact and idempotent.
335
+ const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${params.apId})`;
336
+ const insertObject = db.insert(objects).values({
337
+ apId: params.apId,
338
+ type: "Note",
339
+ attributedTo: params.actorApId,
340
+ content: params.content,
341
+ summary: params.summary,
342
+ attachmentsJson: JSON.stringify(params.attachments || []),
343
+ inReplyTo: params.inReplyTo,
344
+ visibility: params.visibility,
345
+ communityApId: params.communityId,
346
+ toJson: JSON.stringify(addressing.to),
347
+ ccJson: JSON.stringify(addressing.cc),
348
+ audienceJson: JSON.stringify(addressing.audience),
349
+ published: params.now,
350
+ isLocal: 1,
351
+ });
352
+ const bumpPostCount = db
353
+ .update(actors)
354
+ .set({ postCount: sql`${actors.postCount} + 1` })
355
+ .where(and(eq(actors.apId, params.actorApId), objectAbsent));
356
+
357
+ // Direct (DM) posts do NOT count toward postCount: the dedicated DM send path
358
+ // (createDmNote) never bumps it, and the generic DELETE skips the decrement for
359
+ // visibility==='direct'. A direct post created through the generic POST /posts
360
+ // must therefore skip the bump too — otherwise create/delete are asymmetric and
361
+ // postCount over-counts permanently.
362
+ const countStmts = params.visibility === "direct" ? [] : [bumpPostCount];
363
+
364
+ if (params.inReplyTo) {
365
+ const parentId = params.inReplyTo;
366
+ await (db as unknown as Batchable).batch([
367
+ ...countStmts,
368
+ insertObject,
369
+ db
370
+ .update(objects)
371
+ .set({
372
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
373
+ })
374
+ .where(eq(objects.apId, parentId)),
375
+ ] as Parameters<Batchable["batch"]>[0]);
376
+ } else {
377
+ await (db as unknown as Batchable).batch([
378
+ ...countStmts,
379
+ insertObject,
380
+ ] as Parameters<Batchable["batch"]>[0]);
381
+ }
382
+
383
+ if (params.inReplyTo && parentAuthor) {
384
+ if (
385
+ parentAuthor !== params.actorApId &&
386
+ isLocal(parentAuthor, params.baseUrl)
387
+ ) {
388
+ const replyActivityId = activityApId(params.baseUrl, generateId());
389
+ await db.insert(activities).values({
390
+ apId: replyActivityId,
391
+ type: "Create",
392
+ actorApId: params.actorApId,
393
+ objectApId: params.apId,
394
+ rawJson: JSON.stringify({
395
+ "@context": "https://www.w3.org/ns/activitystreams",
396
+ id: replyActivityId,
397
+ type: "Create",
398
+ actor: params.actorApId,
399
+ object: params.apId,
400
+ }),
401
+ createdAt: params.now,
402
+ });
403
+
404
+ await db.insert(inboxTable).values({
405
+ actorApId: parentAuthor,
406
+ activityApId: replyActivityId,
407
+ read: 0,
408
+ createdAt: params.now,
409
+ });
410
+ }
411
+ }
412
+
413
+ return parentAuthor;
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Mention processing
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /**
421
+ * Extract @mentions from content, resolve them to actor AP IDs (local AND
422
+ * remote), build the `Mention` tag array for the outbound Note/Create, create
423
+ * notification activities for LOCAL mentioned actors, and return the resolved
424
+ * actor IRIs so the caller can address (`cc`) and deliver (remote inbox) the
425
+ * Create to every mentioned actor.
426
+ *
427
+ * Remote mentioned actors do not get a local inbox row — they are reached by
428
+ * federated delivery, which the caller enqueues via `enqueueDeliveryToActor`.
429
+ */
430
+
431
+ // Match a cached actor's apId HOST against a mention's `@domain`. A substring
432
+ // test (`apId.includes(domain)`) would resolve a `@user@host.com` mention to a
433
+ // hostile actor whose apId merely CONTAINS the host (e.g.
434
+ // `https://host.com.attacker.test/users/user`), misdirecting the cc + the
435
+ // federated delivery (and, for a followers-only/direct post, handing read access
436
+ // to the wrong actor via isExplicitRecipient). Compare the parsed host exactly.
437
+ function actorHostMatches(apId: string, domain: string): boolean {
438
+ try {
439
+ return new URL(apId).host.toLowerCase() === domain.toLowerCase();
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+
445
+ type MentionActorRow = { apId: string; preferredUsername: string | null };
446
+
447
+ /**
448
+ * Resolve the local (`actors`) and cached-remote (`actor_cache`) rows for the
449
+ * mention tokens of a post. Both lookups are CHUNKED via chunkForInClause: post
450
+ * content allows >100 distinct `@token`s within MAX_POST_CONTENT_LENGTH, and an
451
+ * unchunked `inArray` over that list binds >100 params, exceeding Cloudflare
452
+ * D1's 100-bound-parameter ceiling ("too many SQL variables") — a prod-only
453
+ * failure invisible to the libsql/better-sqlite3 test driver. `remoteMentions`
454
+ * are `user@host` tokens; only their username part is matched here (the caller
455
+ * disambiguates the host).
456
+ */
457
+ async function resolveMentionActorRows(
458
+ db: Database,
459
+ localMentions: string[],
460
+ remoteMentions: string[],
461
+ ): Promise<{
462
+ localActors: MentionActorRow[];
463
+ cachedActors: MentionActorRow[];
464
+ }> {
465
+ const remoteUsernames = remoteMentions.map((m) => m.split("@")[0]);
466
+ const [localActors, cachedActors] = await Promise.all([
467
+ localMentions.length > 0
468
+ ? Promise.all(
469
+ chunkForInClause(localMentions).map((chunk) =>
470
+ db
471
+ .select({
472
+ apId: actors.apId,
473
+ preferredUsername: actors.preferredUsername,
474
+ })
475
+ .from(actors)
476
+ .where(inArray(actors.preferredUsername, chunk)),
477
+ ),
478
+ ).then((rows) => rows.flat())
479
+ : [],
480
+ remoteUsernames.length > 0
481
+ ? Promise.all(
482
+ chunkForInClause(remoteUsernames).map((chunk) =>
483
+ db
484
+ .select({
485
+ apId: actorCache.apId,
486
+ preferredUsername: actorCache.preferredUsername,
487
+ })
488
+ .from(actorCache)
489
+ .where(inArray(actorCache.preferredUsername, chunk)),
490
+ ),
491
+ ).then((rows) => rows.flat())
492
+ : [],
493
+ ]);
494
+ return { localActors, cachedActors };
495
+ }
496
+
497
+ export async function processMentions(
498
+ db: Database,
499
+ params: {
500
+ content: string;
501
+ postApId: string;
502
+ actorApId: string;
503
+ parentAuthor: string | null;
504
+ baseUrl: string;
505
+ now: string;
506
+ },
507
+ ): Promise<ProcessMentionsResult> {
508
+ const mentions = extractMentions(params.content);
509
+ const mentionFailures: MentionFailure[] = [];
510
+ const tags: PostTag[] = [];
511
+ const mentionedActorApIds: string[] = [];
512
+ const remoteMentionedActorApIds: string[] = [];
513
+ const seenMentioned = new Set<string>();
514
+
515
+ // Hashtags federate as standard AS2 `Hashtag` tags (independent of mention
516
+ // resolution) so remote servers can index the post. `href` points at this
517
+ // instance's tag search page (the same destination the web client links to).
518
+ const baseHref = params.baseUrl.replace(/\/+$/, "");
519
+ for (const tag of extractHashtags(params.content)) {
520
+ tags.push({
521
+ type: "Hashtag",
522
+ href: `${baseHref}/search?search=${encodeURIComponent(`#${tag}`)}`,
523
+ name: `#${tag}`,
524
+ });
525
+ }
526
+
527
+ // Persist the computed tag array onto the object row so the served object at
528
+ // `GET /ap/objects/:id` emits the same `tag` the Create carries. The object
529
+ // was already inserted by `insertPostAndHandleReply`, so this is an UPDATE.
530
+ // Only write when there is at least one tag (the column defaults to "[]").
531
+ const persistTags = async () => {
532
+ if (tags.length === 0) return;
533
+ try {
534
+ await db
535
+ .update(objects)
536
+ .set({ tagsJson: JSON.stringify(tags) })
537
+ .where(eq(objects.apId, params.postApId));
538
+ } catch (e) {
539
+ log.error("Failed to persist object tags", {
540
+ event: "posts.mention.tags_persist_failed",
541
+ postApId: params.postApId,
542
+ error: e,
543
+ });
544
+ }
545
+ };
546
+
547
+ const emptyResult: ProcessMentionsResult = {
548
+ failures: mentionFailures,
549
+ tags,
550
+ mentionedActorApIds,
551
+ remoteMentionedActorApIds,
552
+ };
553
+
554
+ // No mentions to resolve — still persist any Hashtag tags before returning.
555
+ if (mentions.length === 0) {
556
+ await persistTags();
557
+ return emptyResult;
558
+ }
559
+
560
+ const localMentions = mentions.filter((m) => !m.includes("@"));
561
+ const remoteMentions = mentions.filter((m) => m.includes("@"));
562
+
563
+ const { localActors, cachedActors } = await resolveMentionActorRows(
564
+ db,
565
+ localMentions,
566
+ remoteMentions,
567
+ );
568
+ const localActorMap = new Map(
569
+ localActors.map((a) => [a.preferredUsername, a.apId]),
570
+ );
571
+
572
+ const remoteActorMap = new Map<string, string>();
573
+ for (const mention of remoteMentions) {
574
+ const [username, domain] = mention.split("@");
575
+ const matching = cachedActors.find(
576
+ (a) =>
577
+ a.preferredUsername === username && actorHostMatches(a.apId, domain),
578
+ );
579
+ if (matching) {
580
+ remoteActorMap.set(mention, matching.apId);
581
+ }
582
+ }
583
+
584
+ const activitiesToCreate: Array<{
585
+ apId: string;
586
+ type: string;
587
+ actorApId: string;
588
+ objectApId: string;
589
+ rawJson: string;
590
+ createdAt: string;
591
+ }> = [];
592
+ const inboxEntriesToCreate: Array<{
593
+ actorApId: string;
594
+ activityApId: string;
595
+ read: number;
596
+ createdAt: string;
597
+ }> = [];
598
+
599
+ for (const mention of mentions) {
600
+ try {
601
+ const mentionedActorApId = mention.includes("@")
602
+ ? remoteActorMap.get(mention) || null
603
+ : localActorMap.get(mention) || null;
604
+
605
+ if (!mentionedActorApId || mentionedActorApId === params.actorApId) {
606
+ continue;
607
+ }
608
+
609
+ const remote = !isLocal(mentionedActorApId, params.baseUrl);
610
+
611
+ // Every resolved mention (local + remote) gets a `Mention` tag and is
612
+ // recorded as a recipient so the caller can address (`cc`) and — for
613
+ // remote actors — deliver the Create to it. `name` uses the canonical
614
+ // `@user@host` acct form so receiving servers can render/notify.
615
+ if (!seenMentioned.has(mentionedActorApId)) {
616
+ seenMentioned.add(mentionedActorApId);
617
+ tags.push({
618
+ type: "Mention",
619
+ href: mentionedActorApId,
620
+ name: `@${formatUsername(mentionedActorApId)}`,
621
+ });
622
+ mentionedActorApIds.push(mentionedActorApId);
623
+ if (remote) remoteMentionedActorApIds.push(mentionedActorApId);
624
+ }
625
+
626
+ // Local notification fan-in only. The parent author is already notified
627
+ // by the reply path, and remote actors are reached by federated delivery
628
+ // (no local inbox row), so skip both here.
629
+ if (params.parentAuthor === mentionedActorApId) continue;
630
+ if (remote) continue;
631
+
632
+ const mentionActivityId = activityApId(params.baseUrl, generateId());
633
+ activitiesToCreate.push({
634
+ apId: mentionActivityId,
635
+ type: "Create",
636
+ actorApId: params.actorApId,
637
+ objectApId: params.postApId,
638
+ rawJson: JSON.stringify({
639
+ "@context": "https://www.w3.org/ns/activitystreams",
640
+ id: mentionActivityId,
641
+ type: "Create",
642
+ actor: params.actorApId,
643
+ object: params.postApId,
644
+ }),
645
+ createdAt: params.now,
646
+ });
647
+
648
+ inboxEntriesToCreate.push({
649
+ actorApId: mentionedActorApId,
650
+ activityApId: mentionActivityId,
651
+ read: 0,
652
+ createdAt: params.now,
653
+ });
654
+ } catch (e) {
655
+ log.error("Failed to process mention", {
656
+ event: "posts.mention.processing_failed",
657
+ mention,
658
+ error: e,
659
+ });
660
+ mentionFailures.push({
661
+ mention,
662
+ stage: "resolve",
663
+ reason: "mention_processing_failed",
664
+ });
665
+ }
666
+ }
667
+
668
+ if (activitiesToCreate.length > 0) {
669
+ try {
670
+ await db.insert(activities).values(activitiesToCreate);
671
+ } catch (e) {
672
+ log.error("Failed to persist mention activities", {
673
+ event: "posts.mention.activity_persist_failed",
674
+ error: e,
675
+ });
676
+ mentionFailures.push({
677
+ mention: "__batch__",
678
+ stage: "persist_activity",
679
+ reason: "mention_activity_persist_failed",
680
+ });
681
+ }
682
+ }
683
+ if (inboxEntriesToCreate.length > 0) {
684
+ try {
685
+ await db.insert(inboxTable).values(inboxEntriesToCreate);
686
+ } catch (e) {
687
+ log.error("Failed to persist mention inbox entries", {
688
+ event: "posts.mention.inbox_persist_failed",
689
+ error: e,
690
+ });
691
+ mentionFailures.push({
692
+ mention: "__batch__",
693
+ stage: "persist_inbox",
694
+ reason: "mention_inbox_persist_failed",
695
+ });
696
+ }
697
+ }
698
+
699
+ // Persist Mention + Hashtag tags onto the object row (see persistTags above).
700
+ await persistTags();
701
+
702
+ return emptyResult;
703
+ }
704
+
705
+ /**
706
+ * Derive the AS2 `tag` array (Hashtag + Mention) for a post's content WITHOUT
707
+ * any notification / activity side effects. The EDIT path uses this so an
708
+ * edited post's served object + Update(Note) carry the SAME tags a fresh post
709
+ * would — re-running the full `processMentions` on every edit would re-notify
710
+ * every mention. Mirrors processMentions' tag-building (kept as a separate,
711
+ * side-effect-free function so the create/federation path stays untouched).
712
+ */
713
+ export async function deriveContentTags(
714
+ db: Database,
715
+ content: string,
716
+ baseUrl: string,
717
+ actorApId: string,
718
+ ): Promise<PostTag[]> {
719
+ const tags: PostTag[] = [];
720
+
721
+ const baseHref = baseUrl.replace(/\/+$/, "");
722
+ for (const tag of extractHashtags(content)) {
723
+ tags.push({
724
+ type: "Hashtag",
725
+ href: `${baseHref}/search?search=${encodeURIComponent(`#${tag}`)}`,
726
+ name: `#${tag}`,
727
+ });
728
+ }
729
+
730
+ const mentions = extractMentions(content);
731
+ if (mentions.length === 0) return tags;
732
+
733
+ const localMentions = mentions.filter((m) => !m.includes("@"));
734
+ const remoteMentions = mentions.filter((m) => m.includes("@"));
735
+
736
+ const { localActors, cachedActors } = await resolveMentionActorRows(
737
+ db,
738
+ localMentions,
739
+ remoteMentions,
740
+ );
741
+ const localActorMap = new Map(
742
+ localActors.map((a) => [a.preferredUsername, a.apId]),
743
+ );
744
+ const remoteActorMap = new Map<string, string>();
745
+ for (const mention of remoteMentions) {
746
+ const [username, domain] = mention.split("@");
747
+ const matching = cachedActors.find(
748
+ (a) =>
749
+ a.preferredUsername === username && actorHostMatches(a.apId, domain),
750
+ );
751
+ if (matching) remoteActorMap.set(mention, matching.apId);
752
+ }
753
+
754
+ const seen = new Set<string>();
755
+ for (const mention of mentions) {
756
+ const apId = mention.includes("@")
757
+ ? remoteActorMap.get(mention) || null
758
+ : localActorMap.get(mention) || null;
759
+ if (!apId || apId === actorApId || seen.has(apId)) continue;
760
+ seen.add(apId);
761
+ tags.push({
762
+ type: "Mention",
763
+ href: apId,
764
+ name: `@${formatUsername(apId)}`,
765
+ });
766
+ }
767
+ return tags;
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Edit validation
772
+ // ---------------------------------------------------------------------------
773
+
774
+ export type EditFieldsResult =
775
+ | {
776
+ ok: true;
777
+ rawBody: Record<string, unknown>;
778
+ body: { content?: string; summary?: string };
779
+ }
780
+ | { ok: false; error: string; code?: string };
781
+
782
+ /**
783
+ * Parse and validate the request body for PATCH (edit post).
784
+ * Returns a discriminated union with the parsed body or error details.
785
+ */
786
+ export async function validateEditBody(c: {
787
+ req: { json: () => Promise<unknown> };
788
+ }): Promise<EditFieldsResult> {
789
+ const rawBody = await parseJsonObject(c);
790
+ if (!rawBody) {
791
+ return { ok: false, error: "Invalid request body", code: "BAD_REQUEST" };
792
+ }
793
+
794
+ for (const field of ["content", "summary"] as const) {
795
+ const err = validateOptionalString(rawBody, field);
796
+ if (err) return { ok: false, error: err, code: "BAD_REQUEST" };
797
+ }
798
+
799
+ const body: { content?: string; summary?: string } = {
800
+ content: typeof rawBody.content === "string" ? rawBody.content : undefined,
801
+ summary: typeof rawBody.summary === "string" ? rawBody.summary : undefined,
802
+ };
803
+
804
+ return { ok: true, rawBody, body };
805
+ }
806
+
807
+ type EditValidation =
808
+ { ok: true; trimmed?: string } | { ok: false; error: string };
809
+
810
+ function validateTrimmedEdit(
811
+ value: string | undefined,
812
+ label: string,
813
+ maxLength: number,
814
+ allowEmpty: boolean,
815
+ ): EditValidation {
816
+ if (value === undefined) return { ok: true };
817
+ const trimmed = value.trim();
818
+ if (!allowEmpty && trimmed.length === 0) {
819
+ return { ok: false, error: `${label} cannot be empty` };
820
+ }
821
+ if (trimmed.length > maxLength) {
822
+ return {
823
+ ok: false,
824
+ error: `${label} too long (max ${maxLength} chars)`,
825
+ };
826
+ }
827
+ return { ok: true, trimmed };
828
+ }
829
+
830
+ /** Validate trimmed content length for editing. */
831
+ export function validateContentEdit(
832
+ content: string | undefined,
833
+ ): EditValidation {
834
+ return validateTrimmedEdit(
835
+ content,
836
+ "Content",
837
+ MAX_POST_CONTENT_LENGTH,
838
+ false,
839
+ );
840
+ }
841
+
842
+ /** Validate trimmed summary length for editing. */
843
+ export function validateSummaryEdit(
844
+ summary: string | undefined,
845
+ ): EditValidation {
846
+ return validateTrimmedEdit(summary, "Summary", MAX_POST_SUMMARY_LENGTH, true);
847
+ }