@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,658 @@
1
+ // Direct Messages - AP Native
2
+ // DMs are Note objects with visibility='direct' and to=[recipient]
3
+ // Threading via conversation field
4
+
5
+ import { Hono } from "hono";
6
+ import { and, desc, eq, inArray, like } from "drizzle-orm";
7
+ import type { Database } from "../../../db/index.ts";
8
+ import {
9
+ activities,
10
+ actorCache,
11
+ actors,
12
+ blocks,
13
+ inbox as inboxTable,
14
+ objectRecipients,
15
+ objects,
16
+ } from "../../../db/index.ts";
17
+ import {
18
+ deleteObjectCascade,
19
+ purgeMediaBlobs,
20
+ } from "../posts/delete-cascade.ts";
21
+ import type { Env, Variables } from "../../types.ts";
22
+ import {
23
+ activityApId,
24
+ formatUsername,
25
+ generateId,
26
+ isLocal,
27
+ objectApId,
28
+ parseLimit,
29
+ safeJsonParse,
30
+ } from "../../federation-helpers.ts";
31
+ import {
32
+ MAX_DM_CONTENT_LENGTH,
33
+ MAX_DM_PAGE_LIMIT,
34
+ resolveConversationId,
35
+ } from "./query-helpers.ts";
36
+ import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
37
+ import { feedCursorWhere } from "../../lib/feed-cursor.ts";
38
+ import { logger } from "../../lib/logger.ts";
39
+
40
+ const log = logger.child({ component: "dm.messages" });
41
+
42
+ // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
43
+ // union; reach it through a narrow structural cast (matching the other routes).
44
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
45
+
46
+ const dm = new Hono<{ Bindings: Env; Variables: Variables }>();
47
+
48
+ type Attachment = {
49
+ type?: string;
50
+ mediaType?: string;
51
+ url?: string;
52
+ [key: string]: unknown;
53
+ };
54
+
55
+ // --- Shared helpers (file-local) ---
56
+
57
+ type ActorInfo = {
58
+ apId: string;
59
+ preferredUsername: string | null;
60
+ name: string | null;
61
+ iconUrl: string | null;
62
+ };
63
+
64
+ type SenderInfo = {
65
+ ap_id: string;
66
+ username: string;
67
+ preferred_username: string | null;
68
+ name: string | null;
69
+ icon_url: string | null;
70
+ };
71
+
72
+ // Only the columns the authorization filter + formatter touch (the fetch query
73
+ // projects exactly these — see fetchAuthorizedMessages; avoids pulling raw_json
74
+ // on the 4s-polled endpoint).
75
+ type DmMessageRow = Pick<
76
+ typeof objects.$inferSelect,
77
+ | "apId"
78
+ | "attributedTo"
79
+ | "content"
80
+ | "attachmentsJson"
81
+ | "published"
82
+ | "toJson"
83
+ >;
84
+
85
+ type DmMessageResponse = {
86
+ id: string;
87
+ sender: SenderInfo;
88
+ content: string | null;
89
+ attachments?: Attachment[];
90
+ created_at: string | null;
91
+ };
92
+
93
+ /** Validate trimmed DM content; returns the trimmed string or an error response. */
94
+ function validateContent(
95
+ raw: unknown,
96
+ ): string | { error: string; status: 400 } {
97
+ // The json<{content:string}>() cast is compile-time only; a client can send a
98
+ // non-string. Guard before .trim() else TypeError → 500 (the global handler
99
+ // deliberately does not mask TypeError as 400). Mirrors the profile/post/invite
100
+ // validators.
101
+ if (typeof raw !== "string") {
102
+ return { error: "Message content is required", status: 400 };
103
+ }
104
+ const content = raw.trim();
105
+ if (!content) return { error: "Message content is required", status: 400 };
106
+ if (content.length > MAX_DM_CONTENT_LENGTH) {
107
+ return {
108
+ error: `Message too long (max ${MAX_DM_CONTENT_LENGTH} chars)`,
109
+ status: 400,
110
+ };
111
+ }
112
+ return content;
113
+ }
114
+
115
+ /**
116
+ * Resolve a `user@domain` handle for a DM recipient. Prefers the stored
117
+ * preferredUsername paired with the recipient's host, falling back to
118
+ * deriving the handle from the actor id (`formatUsername`).
119
+ */
120
+ function resolveRecipientHandle(
121
+ preferredUsername: string | null,
122
+ apId: string,
123
+ ): string {
124
+ if (preferredUsername) {
125
+ try {
126
+ return `${preferredUsername}@${new URL(apId).host}`;
127
+ } catch {
128
+ // fall through to apId-derived handle
129
+ }
130
+ }
131
+ return formatUsername(apId);
132
+ }
133
+
134
+ /** Build a sender info object from a current-session actor. */
135
+ function buildSenderFromActor(actor: {
136
+ ap_id: string;
137
+ preferred_username: string | null;
138
+ name: string | null;
139
+ icon_url: string | null;
140
+ }): SenderInfo {
141
+ return {
142
+ ap_id: actor.ap_id,
143
+ username: formatUsername(actor.ap_id),
144
+ preferred_username: actor.preferred_username,
145
+ name: actor.name,
146
+ icon_url: actor.icon_url,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Fetch direct messages the actor is authorized to see, filtered by
152
+ * conversation. Returns one page (newest-first, capped at `limit`) plus
153
+ * `hasMore` — whether an OLDER page exists — so the thread can offer a "load
154
+ * older" affordance. `before` is the `published` of the oldest message already
155
+ * shown (older messages are fetched with `published < before`).
156
+ */
157
+ async function fetchAuthorizedMessages(
158
+ db: Database,
159
+ actorApId: string,
160
+ conversationId: string,
161
+ limit: number,
162
+ before: string | undefined,
163
+ ): Promise<{ rows: DmMessageRow[]; hasMore: boolean }> {
164
+ // Build where clause: filter by conversation + visibility + type
165
+ // Authorization is re-validated in code below (defense-in-depth)
166
+ const baseCondition = and(
167
+ eq(objects.visibility, "direct"),
168
+ eq(objects.type, "Note"),
169
+ eq(objects.conversation, conversationId),
170
+ );
171
+
172
+ // Composite (published, apId) cursor so two messages sharing a published
173
+ // millisecond aren't skipped on a load-older that straddles that ms (see
174
+ // lib/feed-cursor.ts). The client builds the cursor from the oldest shown
175
+ // message; a published-only value from an older client is still accepted.
176
+ const cursor = feedCursorWhere(objects.published, objects.apId, before);
177
+ const whereClause = cursor ? and(baseCondition!, cursor) : baseCondition;
178
+
179
+ // Fetch one extra row to detect whether an older page exists. Project only the
180
+ // 6 columns the formatter/authorization touch — a bare select() pulled every
181
+ // column incl. the large raw_json blob on a 4s-polled endpoint (mirrors the
182
+ // POST_FEED_COLUMNS projection already used by the timeline).
183
+ const messages = await db
184
+ .select({
185
+ apId: objects.apId,
186
+ attributedTo: objects.attributedTo,
187
+ content: objects.content,
188
+ attachmentsJson: objects.attachmentsJson,
189
+ published: objects.published,
190
+ toJson: objects.toJson,
191
+ })
192
+ .from(objects)
193
+ .where(whereClause!)
194
+ .orderBy(desc(objects.published), desc(objects.apId))
195
+ .limit(limit + 1);
196
+
197
+ // Defence-in-depth: re-validate authorization at the code level. (In practice
198
+ // every row in the actor's own conversation passes, so the +1 reliably signals
199
+ // an older page.)
200
+ const authorized = messages.filter((msg) => {
201
+ if (msg.attributedTo === actorApId) return true;
202
+ const toRecipients = safeJsonParse<string[]>(msg.toJson, []);
203
+ return toRecipients.includes(actorApId);
204
+ });
205
+
206
+ const hasMore = authorized.length > limit;
207
+ return { rows: hasMore ? authorized.slice(0, limit) : authorized, hasMore };
208
+ }
209
+
210
+ /** Build a map from ap_id -> actor info, checking local actors then cached actors. */
211
+ async function resolveAuthorInfoMap(
212
+ db: Database,
213
+ authorApIds: string[],
214
+ ): Promise<Map<string, ActorInfo>> {
215
+ const localActors = await db
216
+ .select({
217
+ apId: actors.apId,
218
+ preferredUsername: actors.preferredUsername,
219
+ name: actors.name,
220
+ iconUrl: actors.iconUrl,
221
+ })
222
+ .from(actors)
223
+ .where(inArray(actors.apId, authorApIds));
224
+
225
+ const localMap = new Map<string, ActorInfo>(
226
+ localActors.map((a) => [a.apId, a]),
227
+ );
228
+
229
+ const remoteApIds = authorApIds.filter((id) => !localMap.has(id));
230
+ if (remoteApIds.length > 0) {
231
+ const cached = await db
232
+ .select({
233
+ apId: actorCache.apId,
234
+ preferredUsername: actorCache.preferredUsername,
235
+ name: actorCache.name,
236
+ iconUrl: actorCache.iconUrl,
237
+ })
238
+ .from(actorCache)
239
+ .where(inArray(actorCache.apId, remoteApIds));
240
+
241
+ for (const a of cached) {
242
+ localMap.set(a.apId, a);
243
+ }
244
+ }
245
+
246
+ return localMap;
247
+ }
248
+
249
+ /** Map raw DB message rows to the API response shape (chronological order). */
250
+ function formatMessages(
251
+ messages: DmMessageRow[],
252
+ authorMap: Map<string, ActorInfo>,
253
+ ): DmMessageResponse[] {
254
+ return messages.reverse().map((msg) => {
255
+ const info = authorMap.get(msg.attributedTo);
256
+ return {
257
+ id: msg.apId,
258
+ sender: {
259
+ ap_id: msg.attributedTo,
260
+ username: formatUsername(msg.attributedTo),
261
+ preferred_username: info?.preferredUsername || null,
262
+ name: info?.name || null,
263
+ icon_url: info?.iconUrl || null,
264
+ },
265
+ content: msg.content,
266
+ attachments: safeJsonParse<Attachment[]>(msg.attachmentsJson, []),
267
+ created_at: msg.published,
268
+ };
269
+ });
270
+ }
271
+
272
+ /** Fetch messages for a conversation, resolve authors, and format for API response. */
273
+ async function fetchAndFormatMessages(
274
+ db: Database,
275
+ actorApId: string,
276
+ conversationId: string,
277
+ limit: number,
278
+ before: string | undefined,
279
+ ): Promise<{ messages: DmMessageResponse[]; hasMore: boolean }> {
280
+ const { rows, hasMore } = await fetchAuthorizedMessages(
281
+ db,
282
+ actorApId,
283
+ conversationId,
284
+ limit,
285
+ before,
286
+ );
287
+ const authorApIds = [...new Set(rows.map((m) => m.attributedTo))];
288
+ const authorMap = await resolveAuthorInfoMap(db, authorApIds);
289
+ return { messages: formatMessages(rows, authorMap), hasMore };
290
+ }
291
+
292
+ /** Look up a direct-message Note that the actor owns (for edit/delete). */
293
+ async function findOwnedDmMessage(
294
+ db: Database,
295
+ messageId: string,
296
+ actorApId: string,
297
+ ): Promise<
298
+ | { apId: string; attributedTo: string; conversation: string | null }
299
+ | {
300
+ error: string;
301
+ status: 403 | 404;
302
+ }
303
+ > {
304
+ const message = await db
305
+ .select({
306
+ apId: objects.apId,
307
+ attributedTo: objects.attributedTo,
308
+ conversation: objects.conversation,
309
+ })
310
+ .from(objects)
311
+ .where(
312
+ and(
313
+ eq(objects.apId, messageId),
314
+ eq(objects.visibility, "direct"),
315
+ eq(objects.type, "Note"),
316
+ ),
317
+ )
318
+ .get();
319
+
320
+ if (!message) return { error: "Message not found", status: 404 };
321
+ if (message.attributedTo !== actorApId) {
322
+ return { error: "Forbidden", status: 403 };
323
+ }
324
+ return message;
325
+ }
326
+
327
+ /** Create the DM Note object row. */
328
+ // Build (but do not execute) the insert for a DM Note. Returned as a statement
329
+ // so the caller can co-commit it with the recipient/activity/inbox rows in one
330
+ // atomic batch (D1 has no interactive transactions).
331
+ function dmNoteInsert(
332
+ db: Database,
333
+ data: {
334
+ apId: string;
335
+ actorApId: string;
336
+ content: string;
337
+ toJson: string;
338
+ conversationId: string;
339
+ published: string;
340
+ },
341
+ ) {
342
+ return db.insert(objects).values({
343
+ apId: data.apId,
344
+ type: "Note",
345
+ attributedTo: data.actorApId,
346
+ content: data.content,
347
+ visibility: "direct",
348
+ toJson: data.toJson,
349
+ ccJson: JSON.stringify([]),
350
+ conversation: data.conversationId,
351
+ published: data.published,
352
+ isLocal: 1,
353
+ });
354
+ }
355
+
356
+ // --- Route handlers ---
357
+
358
+ dm.get("/user/:encodedApId/messages", async (c) => {
359
+ const actor = c.get("actor");
360
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
361
+
362
+ const db = c.get("db");
363
+ const otherApId = decodeURIComponent(c.req.param("encodedApId"));
364
+ const limit = parseLimit(c.req.query("limit"), 50, MAX_DM_PAGE_LIMIT);
365
+ const before = c.req.query("before");
366
+ // Resolve to the STORED conversation id of an existing thread rather than
367
+ // always recomputing the current-scheme id, so messages/read-status stay
368
+ // matched for threads created before the current id scheme.
369
+ const conversationId = await resolveConversationId(
370
+ db,
371
+ c.env.APP_URL,
372
+ actor.ap_id,
373
+ otherApId,
374
+ );
375
+
376
+ const { messages, hasMore } = await fetchAndFormatMessages(
377
+ db,
378
+ actor.ap_id,
379
+ conversationId,
380
+ limit,
381
+ before,
382
+ );
383
+ return c.json({
384
+ messages,
385
+ conversation_id: conversationId,
386
+ has_more: hasMore,
387
+ });
388
+ });
389
+
390
+ // Send message to a specific user (creates Note with direct visibility)
391
+ dm.post("/user/:encodedApId/messages", async (c) => {
392
+ const actor = c.get("actor");
393
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
394
+
395
+ const db = c.get("db");
396
+ const otherApId = decodeURIComponent(c.req.param("encodedApId"));
397
+ const body = await c.req.json<{ content: string }>();
398
+ const baseUrl = c.env.APP_URL;
399
+
400
+ const contentOrError = validateContent(body.content);
401
+ if (typeof contentOrError !== "string") {
402
+ return c.json({ error: contentOrError.error }, contentOrError.status);
403
+ }
404
+ const content = contentOrError;
405
+
406
+ // Verify other user exists (check both local actors and cached remote actors)
407
+ const localActor = await db
408
+ .select({
409
+ apId: actors.apId,
410
+ inbox: actors.inbox,
411
+ preferredUsername: actors.preferredUsername,
412
+ })
413
+ .from(actors)
414
+ .where(eq(actors.apId, otherApId))
415
+ .get();
416
+
417
+ const cachedActor = !localActor
418
+ ? await db
419
+ .select({
420
+ apId: actorCache.apId,
421
+ inbox: actorCache.inbox,
422
+ preferredUsername: actorCache.preferredUsername,
423
+ })
424
+ .from(actorCache)
425
+ .where(eq(actorCache.apId, otherApId))
426
+ .get()
427
+ : null;
428
+
429
+ const otherActor = localActor || cachedActor;
430
+ if (!otherActor) return c.json({ error: "User not found" }, 404);
431
+
432
+ // Reject if the recipient has blocked the sender. Respond with 404 (the same
433
+ // shape as a non-existent recipient) so the sender cannot distinguish a block
434
+ // from a missing user and thereby learn they were blocked.
435
+ const blockedBy = await db
436
+ .select({ blockerApId: blocks.blockerApId })
437
+ .from(blocks)
438
+ .where(
439
+ and(
440
+ eq(blocks.blockerApId, otherApId),
441
+ eq(blocks.blockedApId, actor.ap_id),
442
+ ),
443
+ )
444
+ .get();
445
+ if (blockedBy) return c.json({ error: "User not found" }, 404);
446
+
447
+ const apId = objectApId(baseUrl, generateId());
448
+ const now = new Date().toISOString();
449
+ // Reuse an existing thread's stored conversation id so a reply does not split
450
+ // a thread created before the current id scheme into a second id; a brand new
451
+ // conversation falls back to the current-scheme id.
452
+ const conversationId = await resolveConversationId(
453
+ db,
454
+ baseUrl,
455
+ actor.ap_id,
456
+ otherApId,
457
+ );
458
+ const toJson = JSON.stringify([otherApId]);
459
+
460
+ const isRecipientLocal = !!localActor;
461
+ const deliveryActivityId = activityApId(baseUrl, generateId());
462
+ // Address the recipient with a Mention tag so remote servers (e.g. Mastodon)
463
+ // surface the DM as a notification. Prefer the stored preferredUsername, then
464
+ // fall back to deriving user@domain from the recipient actor id.
465
+ const recipientName = `@${resolveRecipientHandle(otherActor.preferredUsername, otherApId)}`;
466
+ const mentionTag = [
467
+ { type: "Mention", href: otherApId, name: recipientName },
468
+ ];
469
+ const remoteCreateActivity = !isRecipientLocal
470
+ ? {
471
+ "@context": "https://www.w3.org/ns/activitystreams",
472
+ id: deliveryActivityId,
473
+ type: "Create",
474
+ actor: actor.ap_id,
475
+ to: [otherApId],
476
+ tag: mentionTag,
477
+ object: {
478
+ id: apId,
479
+ type: "Note",
480
+ attributedTo: actor.ap_id,
481
+ to: [otherApId],
482
+ content,
483
+ published: now,
484
+ conversation: conversationId,
485
+ tag: mentionTag,
486
+ },
487
+ }
488
+ : null;
489
+
490
+ // Co-commit the message atomically. D1 has no interactive transactions; a
491
+ // sequence of separate inserts could commit the Note without its
492
+ // object_recipients row, and the recipient's DM reader resolves membership
493
+ // ONLY via object_recipients — so an orphan Note would be permanently
494
+ // invisible to the recipient. batch() is atomic (mirrors the community-chat
495
+ // send), so the Note + recipient + activity (+ inbox notification) land or
496
+ // fail together.
497
+ const noteStmt = dmNoteInsert(db, {
498
+ apId,
499
+ actorApId: actor.ap_id,
500
+ content,
501
+ toJson,
502
+ conversationId,
503
+ published: now,
504
+ });
505
+ const batchOps = isRecipientLocal
506
+ ? [
507
+ noteStmt,
508
+ db
509
+ .insert(objectRecipients)
510
+ .values({ objectApId: apId, recipientApId: otherApId, type: "to" })
511
+ .onConflictDoNothing(),
512
+ db.insert(activities).values({
513
+ apId: deliveryActivityId,
514
+ type: "Create",
515
+ actorApId: actor.ap_id,
516
+ objectApId: apId,
517
+ rawJson: JSON.stringify({
518
+ type: "Create",
519
+ actor: actor.ap_id,
520
+ object: apId,
521
+ }),
522
+ direction: "inbound",
523
+ }),
524
+ db.insert(inboxTable).values({
525
+ actorApId: otherApId,
526
+ activityApId: deliveryActivityId,
527
+ }),
528
+ ]
529
+ : [
530
+ noteStmt,
531
+ db.insert(activities).values({
532
+ apId: deliveryActivityId,
533
+ type: "Create",
534
+ actorApId: actor.ap_id,
535
+ objectApId: apId,
536
+ rawJson: JSON.stringify(remoteCreateActivity),
537
+ direction: "outbound",
538
+ }),
539
+ ];
540
+
541
+ try {
542
+ await (db as unknown as Batchable).batch(batchOps);
543
+ } catch (e) {
544
+ log.error("Failed to insert message", {
545
+ event: "dm.message.insert_failed",
546
+ actor: actor.ap_id,
547
+ recipient: otherApId,
548
+ error: e,
549
+ });
550
+ return c.json({ error: "Failed to send message" }, 500);
551
+ }
552
+
553
+ if (!isLocal(otherApId, baseUrl)) {
554
+ await enqueueDeliveryToActor(c.env, deliveryActivityId, otherApId);
555
+ }
556
+
557
+ return c.json(
558
+ {
559
+ message: {
560
+ id: apId,
561
+ sender: buildSenderFromActor(actor),
562
+ content,
563
+ created_at: now,
564
+ },
565
+ conversation_id: conversationId,
566
+ },
567
+ 201,
568
+ );
569
+ });
570
+
571
+ // Edit a DM message
572
+ dm.patch("/messages/:messageId", async (c) => {
573
+ const actor = c.get("actor");
574
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
575
+
576
+ const db = c.get("db");
577
+ const body = await c.req.json<{ content: string }>();
578
+
579
+ const contentOrError = validateContent(body.content);
580
+ if (typeof contentOrError !== "string") {
581
+ return c.json({ error: contentOrError.error }, contentOrError.status);
582
+ }
583
+ const content = contentOrError;
584
+
585
+ const messageOrError = await findOwnedDmMessage(
586
+ db,
587
+ c.req.param("messageId"),
588
+ actor.ap_id,
589
+ );
590
+ if ("error" in messageOrError) {
591
+ return c.json({ error: messageOrError.error }, messageOrError.status);
592
+ }
593
+ const message = messageOrError;
594
+
595
+ const now = new Date().toISOString();
596
+ await db
597
+ .update(objects)
598
+ .set({ content, updated: now })
599
+ .where(eq(objects.apId, message.apId));
600
+
601
+ return c.json({
602
+ success: true,
603
+ message: { id: message.apId, content, updated_at: now },
604
+ });
605
+ });
606
+
607
+ // Delete a DM message
608
+ dm.delete("/messages/:messageId", async (c) => {
609
+ const actor = c.get("actor");
610
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
611
+
612
+ const db = c.get("db");
613
+
614
+ const messageOrError = await findOwnedDmMessage(
615
+ db,
616
+ c.req.param("messageId"),
617
+ actor.ap_id,
618
+ );
619
+ if ("error" in messageOrError) {
620
+ return c.json({ error: messageOrError.error }, messageOrError.status);
621
+ }
622
+ const message = messageOrError;
623
+
624
+ // Sequential operations (D1 doesn't support interactive transactions).
625
+ // Also remove the delivery Create activity + the recipient's inbox row created
626
+ // at send time (messages.ts send path). These tables are addressed by AP id
627
+ // with no FK to `objects`, so deleting only the object orphans them — and
628
+ // because the notifications query LEFT JOINs the now-missing object (a
629
+ // deleted DM's object is gone → NULL visibility → not excluded as "direct"),
630
+ // the orphan Create inbox row would resurface as a blank "mention"
631
+ // notification with a dead /post link. Drop them first.
632
+ const relatedActivities = await db
633
+ .select({ apId: activities.apId })
634
+ .from(activities)
635
+ .where(eq(activities.objectApId, message.apId));
636
+ const activityIds = relatedActivities.map((a) => a.apId);
637
+ if (activityIds.length > 0) {
638
+ await db
639
+ .delete(inboxTable)
640
+ .where(inArray(inboxTable.activityApId, activityIds));
641
+ await db.delete(activities).where(inArray(activities.apId, activityIds));
642
+ }
643
+ // Reap the message's child rows AND any attached R2 blob + media_uploads row
644
+ // via the shared cascade (covers objectRecipients + media + likes/announces/
645
+ // bookmarks/story*), then drop the object. Local DMs are text-only today so
646
+ // there is no blob to leak yet, but routing through the cascade now keeps DM
647
+ // deletion correct the moment DM media upload is wired in (and a leaked DM
648
+ // blob would be PRIVATE) — matching the post and story delete paths.
649
+ const mediaKeys = await deleteObjectCascade(db, message.apId, c.env.MEDIA);
650
+ await db.delete(objects).where(eq(objects.apId, message.apId));
651
+ // Irreversible R2 purge LAST — after the objects row is gone, so a failed
652
+ // object delete can't leave a live (private) DM pointing at a deleted blob.
653
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
654
+
655
+ return c.json({ success: true });
656
+ });
657
+
658
+ export default dm;