@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,85 @@
1
+ import { and, desc, eq, inArray, isNotNull, or } from "drizzle-orm";
2
+ import type { Database } from "../../../db/index.ts";
3
+ import { objects } from "../../../db/index.ts";
4
+ import { recipientObjectIds } from "./conversations-helpers.ts";
5
+ import { base64UrlEncode } from "../../lib/oauth-utils.ts";
6
+
7
+ export const MAX_DM_CONTENT_LENGTH = 5000;
8
+ // Capped at 90 (not 100): a page of DM ids is re-queried via `inArray` for
9
+ // author/recipient enrichment, and Cloudflare D1 allows at most 100 bound
10
+ // parameters per query. (Tests run on libsql's ~32k ceiling, which hides this.)
11
+ export const MAX_DM_PAGE_LIMIT = 90;
12
+
13
+ /**
14
+ * Derive the deterministic conversation ID for a DM between two actors.
15
+ *
16
+ * The two participant AP-IDs are sorted (so the order in which the pair is
17
+ * supplied does not matter) and joined with a newline — a byte that cannot
18
+ * appear inside an AP-ID URL, so it is an unambiguous separator. The joined
19
+ * pair is then base64url-encoded WITHOUT truncation, which makes the mapping
20
+ * from actor-pair to conversation ID injective: distinct pairs always produce
21
+ * distinct IDs, eliminating the collisions that the previous 16-char,
22
+ * alnum-stripped base64 truncation produced (two same-host actor URLs share a
23
+ * long common base64 prefix, so the old scheme collapsed every same-host pair
24
+ * onto the same ID).
25
+ *
26
+ * Migration note: this changes the ID derived for a given pair, so DM objects,
27
+ * read status and archive rows written under the old truncated scheme will not
28
+ * be matched by the new ID. `resolveConversationId` first looks up the existing
29
+ * conversation ID stored on prior DM objects, so already-threaded conversations
30
+ * keep working; only the synchronous `getConversationId` fallback (no prior
31
+ * message, archive, read-status) changes. There is no automatic backfill: the
32
+ * old IDs were collision-prone and are intentionally not reproduced.
33
+ */
34
+ export function getConversationId(
35
+ baseUrl: string,
36
+ ap1: string,
37
+ ap2: string,
38
+ ): string {
39
+ const [p1, p2] = [ap1, ap2].sort();
40
+ const bytes = new TextEncoder().encode(`${p1}\n${p2}`);
41
+ // base64url, no padding: URL-safe and free of characters that would be
42
+ // stripped, so the full (collision-free) encoding is preserved.
43
+ const hash = base64UrlEncode(bytes.buffer);
44
+ return `${baseUrl}/ap/conversations/${hash}`;
45
+ }
46
+
47
+ export async function resolveConversationId(
48
+ db: Database,
49
+ baseUrl: string,
50
+ actorApId: string,
51
+ otherApId: string,
52
+ ): Promise<string> {
53
+ // Find existing conversation between these two actors
54
+ const existing = await db
55
+ .select({ conversation: objects.conversation })
56
+ .from(objects)
57
+ .where(
58
+ and(
59
+ eq(objects.visibility, "direct"),
60
+ eq(objects.type, "Note"),
61
+ isNotNull(objects.conversation),
62
+ // A DM between this pair = a Note authored by one and addressed to the
63
+ // other. Recipient membership is resolved via the indexed
64
+ // object_recipients link (see recipientObjectIds) instead of an
65
+ // unindexable `to_json LIKE '%"<apId>"%'` scan; same semantics.
66
+ or(
67
+ and(
68
+ eq(objects.attributedTo, actorApId),
69
+ inArray(objects.apId, recipientObjectIds(db, otherApId)),
70
+ ),
71
+ and(
72
+ eq(objects.attributedTo, otherApId),
73
+ inArray(objects.apId, recipientObjectIds(db, actorApId)),
74
+ ),
75
+ ),
76
+ ),
77
+ )
78
+ .orderBy(desc(objects.published))
79
+ .limit(1)
80
+ .get();
81
+
82
+ return (
83
+ existing?.conversation || getConversationId(baseUrl, actorApId, otherApId)
84
+ );
85
+ }
@@ -0,0 +1,228 @@
1
+ // DM read status and archive management
2
+
3
+ import { Hono } from "hono";
4
+ import { and, desc, eq } from "drizzle-orm";
5
+ import {
6
+ communities,
7
+ communityMembers,
8
+ dmArchivedConversations,
9
+ dmCommunityReadStatus,
10
+ dmReadStatus,
11
+ objects,
12
+ } from "../../../db/index.ts";
13
+ import { resolveConversationId } from "./query-helpers.ts";
14
+ import {
15
+ buildActorInfoMap,
16
+ byTimeDesc,
17
+ dmWhereForActor,
18
+ formatActorProfile,
19
+ groupConversations,
20
+ type HonoEnv,
21
+ parseOtherApId,
22
+ uniqueValues,
23
+ } from "./conversations-helpers.ts";
24
+
25
+ const readArchive = new Hono<HonoEnv>();
26
+
27
+ // Mark conversation as read
28
+ readArchive.post("/user/:encodedApId/read", async (c) => {
29
+ const actor = c.get("actor");
30
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
31
+ const db = c.get("db");
32
+
33
+ const otherApId = parseOtherApId(c);
34
+ if (!otherApId) return c.json({ error: "ap_id required" }, 400);
35
+
36
+ const baseUrl = c.env.APP_URL;
37
+ const conversationId = await resolveConversationId(
38
+ db,
39
+ baseUrl,
40
+ actor.ap_id,
41
+ otherApId,
42
+ );
43
+ const now = new Date().toISOString();
44
+
45
+ await db
46
+ .insert(dmReadStatus)
47
+ .values({
48
+ actorApId: actor.ap_id,
49
+ conversationId,
50
+ lastReadAt: now,
51
+ })
52
+ .onConflictDoUpdate({
53
+ target: [dmReadStatus.actorApId, dmReadStatus.conversationId],
54
+ set: { lastReadAt: now },
55
+ });
56
+
57
+ return c.json({ success: true, last_read_at: now });
58
+ });
59
+
60
+ // Mark a community (group chat) as read for the current viewer.
61
+ // Mirrors the one-to-one DM read endpoint above; the unread baseline used by
62
+ // GET /contacts is the recorded `last_read_at`.
63
+ readArchive.post("/community/:encodedApId/read", async (c) => {
64
+ const actor = c.get("actor");
65
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
66
+ const db = c.get("db");
67
+
68
+ const communityApId = parseOtherApId(c);
69
+ if (!communityApId) return c.json({ error: "ap_id required" }, 400);
70
+
71
+ // Only track read state for communities that actually exist; otherwise an
72
+ // arbitrary AP-ID could accumulate orphan rows.
73
+ const community = await db
74
+ .select({ apId: communities.apId })
75
+ .from(communities)
76
+ .where(eq(communities.apId, communityApId))
77
+ .get();
78
+ if (!community) return c.json({ error: "Community not found" }, 404);
79
+
80
+ // Only members may track a read position for a community group chat: the
81
+ // unread baseline is meaningless for non-members and would let an arbitrary
82
+ // actor write read-status rows for communities they cannot see.
83
+ const membership = await db
84
+ .select({ actorApId: communityMembers.actorApId })
85
+ .from(communityMembers)
86
+ .where(
87
+ and(
88
+ eq(communityMembers.communityApId, communityApId),
89
+ eq(communityMembers.actorApId, actor.ap_id),
90
+ ),
91
+ )
92
+ .get();
93
+ if (!membership) return c.json({ error: "Not a community member" }, 403);
94
+
95
+ const now = new Date().toISOString();
96
+ await db
97
+ .insert(dmCommunityReadStatus)
98
+ .values({
99
+ actorApId: actor.ap_id,
100
+ communityApId,
101
+ lastReadAt: now,
102
+ })
103
+ .onConflictDoUpdate({
104
+ target: [
105
+ dmCommunityReadStatus.actorApId,
106
+ dmCommunityReadStatus.communityApId,
107
+ ],
108
+ set: { lastReadAt: now },
109
+ });
110
+
111
+ return c.json({ success: true, last_read_at: now });
112
+ });
113
+
114
+ // Archive a conversation (hide from inbox)
115
+ readArchive.post("/user/:encodedApId/archive", async (c) => {
116
+ const actor = c.get("actor");
117
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
118
+ const db = c.get("db");
119
+
120
+ const otherApId = parseOtherApId(c);
121
+ if (!otherApId) return c.json({ error: "ap_id required" }, 400);
122
+
123
+ const baseUrl = c.env.APP_URL;
124
+ // Match the STORED conversation id (legacy- or current-scheme) so archiving a
125
+ // pre-migration conversation actually hides it (the contacts/unread paths key
126
+ // on the same stored id) instead of writing a never-matching current-scheme row.
127
+ const conversationId = await resolveConversationId(
128
+ db,
129
+ baseUrl,
130
+ actor.ap_id,
131
+ otherApId,
132
+ );
133
+ const now = new Date().toISOString();
134
+
135
+ await db
136
+ .insert(dmArchivedConversations)
137
+ .values({
138
+ actorApId: actor.ap_id,
139
+ conversationId,
140
+ archivedAt: now,
141
+ })
142
+ .onConflictDoNothing();
143
+
144
+ return c.json({ success: true, archived_at: now });
145
+ });
146
+
147
+ // Unarchive a conversation
148
+ readArchive.delete("/user/:encodedApId/archive", async (c) => {
149
+ const actor = c.get("actor");
150
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
151
+ const db = c.get("db");
152
+
153
+ const otherApId = parseOtherApId(c);
154
+ if (!otherApId) return c.json({ error: "ap_id required" }, 400);
155
+
156
+ const baseUrl = c.env.APP_URL;
157
+ const conversationId = await resolveConversationId(
158
+ db,
159
+ baseUrl,
160
+ actor.ap_id,
161
+ otherApId,
162
+ );
163
+
164
+ await db
165
+ .delete(dmArchivedConversations)
166
+ .where(
167
+ and(
168
+ eq(dmArchivedConversations.actorApId, actor.ap_id),
169
+ eq(dmArchivedConversations.conversationId, conversationId),
170
+ ),
171
+ );
172
+
173
+ return c.json({ success: true });
174
+ });
175
+
176
+ // Get archived conversations
177
+ readArchive.get("/archived", async (c) => {
178
+ const actor = c.get("actor");
179
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
180
+ const db = c.get("db");
181
+
182
+ const archivedConversations = await db
183
+ .select({
184
+ conversationId: dmArchivedConversations.conversationId,
185
+ archivedAt: dmArchivedConversations.archivedAt,
186
+ })
187
+ .from(dmArchivedConversations)
188
+ .where(eq(dmArchivedConversations.actorApId, actor.ap_id));
189
+
190
+ if (archivedConversations.length === 0) {
191
+ return c.json({ archived: [] });
192
+ }
193
+
194
+ const archivedSet = new Set(
195
+ archivedConversations.map((a) => a.conversationId),
196
+ );
197
+
198
+ const dmObjects = await db
199
+ .select({
200
+ conversation: objects.conversation,
201
+ attributedTo: objects.attributedTo,
202
+ toJson: objects.toJson,
203
+ published: objects.published,
204
+ })
205
+ .from(objects)
206
+ .where(dmWhereForActor(db, actor.ap_id)!)
207
+ .orderBy(desc(objects.published))
208
+ .limit(2000);
209
+
210
+ const conversationMap = groupConversations(dmObjects, actor.ap_id, (id) =>
211
+ archivedSet.has(id),
212
+ );
213
+
214
+ const otherApIds = uniqueValues(conversationMap, (c) => c.otherApId);
215
+ const actorInfoMap = await buildActorInfoMap(db, otherApIds);
216
+
217
+ const archived = Array.from(conversationMap.values())
218
+ .map((conv) => ({
219
+ ...formatActorProfile(conv.otherApId, actorInfoMap.get(conv.otherApId)),
220
+ conversation_id: conv.conversation,
221
+ last_message_at: conv.lastMessageAt,
222
+ }))
223
+ .sort((a, b) => byTimeDesc(a.last_message_at, b.last_message_at));
224
+
225
+ return c.json({ archived });
226
+ });
227
+
228
+ export default readArchive;
@@ -0,0 +1,222 @@
1
+ // DM requests - list, accept, reject
2
+
3
+ import { Hono } from "hono";
4
+ import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
5
+ import {
6
+ activities,
7
+ blocks,
8
+ inbox as inboxTable,
9
+ objectRecipients,
10
+ objects,
11
+ } from "../../../db/index.ts";
12
+ import { resolveConversationId } from "./query-helpers.ts";
13
+ import {
14
+ buildActorInfoMap,
15
+ findRepliedConversations,
16
+ formatActorProfile,
17
+ type HonoEnv,
18
+ recipientObjectIds,
19
+ } from "./conversations-helpers.ts";
20
+
21
+ // Upper bound on the number of pending-request CONVERSATIONS returned. The list
22
+ // is collapsed to one row per conversation in SQL (GROUP BY), so this bounds
23
+ // distinct senders, NOT messages — a single high-volume sender can occupy at
24
+ // most one slot and can no longer evict every other pending requester.
25
+ const MAX_REQUEST_CONVERSATIONS = 500;
26
+
27
+ const requests = new Hono<HonoEnv>();
28
+
29
+ // Get message requests (DMs from people we haven't replied to)
30
+ requests.get("/requests", async (c) => {
31
+ const actor = c.get("actor");
32
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
33
+ const db = c.get("db");
34
+
35
+ // Collapse to ONE row per conversation IN SQL (GROUP BY) — the latest message
36
+ // of each — then bound by conversation count. Previously we fetched the 1000
37
+ // most-recent incoming MESSAGES and deduped in JS, so a single sender with
38
+ // >1000 stored DMs filled the whole window and evicted every other pending
39
+ // request (and diverged from the unbounded request_count badge). `max()` with
40
+ // GROUP BY makes the bare columns take their value from each group's latest
41
+ // message (SQLite min/max bare-column rule).
42
+ const latestPerConversation = await db
43
+ .select({
44
+ apId: objects.apId,
45
+ attributedTo: objects.attributedTo,
46
+ content: objects.content,
47
+ published: sql<string>`max(${objects.published})`,
48
+ conversation: objects.conversation,
49
+ })
50
+ .from(objects)
51
+ .where(
52
+ and(
53
+ eq(objects.visibility, "direct"),
54
+ eq(objects.type, "Note"),
55
+ isNotNull(objects.conversation),
56
+ // Incoming DMs = Notes where this actor is a `to` recipient. Resolved
57
+ // via the indexed object_recipients link (see recipientObjectIds)
58
+ // instead of an unindexable `to_json LIKE '%"<apId>"%'` scan; same
59
+ // recipient-membership semantics.
60
+ inArray(objects.apId, recipientObjectIds(db, actor.ap_id)),
61
+ ),
62
+ )
63
+ .groupBy(objects.conversation)
64
+ .orderBy(desc(sql`max(${objects.published})`))
65
+ .limit(MAX_REQUEST_CONVERSATIONS);
66
+
67
+ const allConversations = latestPerConversation
68
+ .map((dm) => dm.conversation)
69
+ .filter((c): c is string => c !== null);
70
+ const repliedConversationsSet = await findRepliedConversations(
71
+ db,
72
+ allConversations,
73
+ actor.ap_id,
74
+ );
75
+
76
+ // Keep only unreplied conversations; GROUP BY already guarantees one row each.
77
+ const requestList = latestPerConversation
78
+ .filter(
79
+ (dm) => dm.conversation && !repliedConversationsSet.has(dm.conversation),
80
+ )
81
+ .map((dm) => ({
82
+ id: dm.apId,
83
+ senderApId: dm.attributedTo,
84
+ content: dm.content,
85
+ createdAt: dm.published,
86
+ conversation: dm.conversation,
87
+ }));
88
+
89
+ const senderApIds = [...new Set(requestList.map((r) => r.senderApId))];
90
+ const actorInfoMap = await buildActorInfoMap(db, senderApIds);
91
+
92
+ const result = requestList.map((r) => ({
93
+ id: r.id,
94
+ sender: formatActorProfile(r.senderApId, actorInfoMap.get(r.senderApId)),
95
+ content: r.content,
96
+ created_at: r.createdAt,
97
+ conversation: r.conversation,
98
+ }));
99
+
100
+ return c.json({ requests: result });
101
+ });
102
+
103
+ // Reject request = delete messages from a sender and optionally block
104
+ requests.post("/requests/reject", async (c) => {
105
+ const actor = c.get("actor");
106
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
107
+ const db = c.get("db");
108
+
109
+ const body = await c.req.json<{ sender_ap_id: string; block?: boolean }>();
110
+ if (!body.sender_ap_id) {
111
+ return c.json({ error: "sender_ap_id is required" }, 400);
112
+ }
113
+
114
+ const baseUrl = c.env.APP_URL;
115
+ // Resolve to the STORED conversation id (legacy- or current-scheme) so a
116
+ // pre-migration thread is actually matched by the deletes below.
117
+ const conversationId = await resolveConversationId(
118
+ db,
119
+ baseUrl,
120
+ actor.ap_id,
121
+ body.sender_ap_id,
122
+ );
123
+
124
+ // The set of the sender's messages in this conversation, as a reusable
125
+ // SUBQUERY (never materialised into an `IN (...)`, which would blow D1's
126
+ // 100-bound-parameter ceiling once a spammer has written >~100 messages).
127
+ const senderObjectIds = db
128
+ .select({ apId: objects.apId })
129
+ .from(objects)
130
+ .where(
131
+ and(
132
+ eq(objects.conversation, conversationId),
133
+ eq(objects.attributedTo, body.sender_ap_id),
134
+ ),
135
+ );
136
+
137
+ // Drop the inbox + delivery Create activities for those messages FIRST, before
138
+ // the objects vanish. These tables are addressed by AP id with no FK to
139
+ // `objects`, so deleting only the object orphans them — and the notifications
140
+ // query LEFT JOINs the now-missing object (gone → NULL visibility → no longer
141
+ // excluded as "direct"), so each orphan Create would resurface as a blank
142
+ // "mention" notification with a dead link plus an inflated unread badge. This
143
+ // mirrors the DM message-delete cleanup (messages.ts).
144
+ await db
145
+ .delete(inboxTable)
146
+ .where(
147
+ inArray(
148
+ inboxTable.activityApId,
149
+ db
150
+ .select({ apId: activities.apId })
151
+ .from(activities)
152
+ .where(inArray(activities.objectApId, senderObjectIds)),
153
+ ),
154
+ );
155
+ await db
156
+ .delete(activities)
157
+ .where(inArray(activities.objectApId, senderObjectIds));
158
+
159
+ // Delete the recipient rows for every message the sender wrote in this
160
+ // conversation (same subquery-not-IN reasoning as above).
161
+ await db
162
+ .delete(objectRecipients)
163
+ .where(inArray(objectRecipients.objectApId, senderObjectIds));
164
+
165
+ await db
166
+ .delete(objects)
167
+ .where(
168
+ and(
169
+ eq(objects.conversation, conversationId),
170
+ eq(objects.visibility, "direct"),
171
+ eq(objects.attributedTo, body.sender_ap_id),
172
+ ),
173
+ );
174
+
175
+ if (body.block) {
176
+ await db
177
+ .insert(blocks)
178
+ .values({
179
+ blockerApId: actor.ap_id,
180
+ blockedApId: body.sender_ap_id,
181
+ })
182
+ .onConflictDoNothing();
183
+ }
184
+
185
+ return c.json({ success: true });
186
+ });
187
+
188
+ // Accept request.
189
+ //
190
+ // In this AP-native DM model there is no separate "accepted" state to persist:
191
+ // a conversation is treated as a pending request precisely until the recipient
192
+ // sends their first reply (see findRepliedConversations / the /requests list),
193
+ // at which point it leaves the request list and the sender becomes a contact.
194
+ // There is therefore nothing this endpoint can record server-side that would
195
+ // make the request "accepted" without actually sending a reply.
196
+ //
197
+ // Rather than fake a success that immediately reverts on the next reload (the
198
+ // previous behaviour returned { success: true } while changing no state, so the
199
+ // request reappeared and no contact was created), respond honestly: acceptance
200
+ // is performed by replying via POST /api/dm/user/:encodedApId/messages.
201
+ requests.post("/requests/accept", async (c) => {
202
+ const actor = c.get("actor");
203
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
204
+
205
+ const body = await c.req.json<{ sender_ap_id: string }>();
206
+ if (!body.sender_ap_id) {
207
+ return c.json({ error: "sender_ap_id is required" }, 400);
208
+ }
209
+
210
+ return c.json(
211
+ {
212
+ error: "not_implemented",
213
+ message:
214
+ "Accepting a request is done by replying to the conversation " +
215
+ "(POST /api/dm/user/:encodedApId/messages). There is no separate " +
216
+ "accept action in this DM model.",
217
+ },
218
+ 501,
219
+ );
220
+ });
221
+
222
+ export default requests;
@@ -0,0 +1,81 @@
1
+ // DM typing indicators
2
+
3
+ import { Hono } from "hono";
4
+ import { and, eq } from "drizzle-orm";
5
+ import { dmTyping } from "../../../db/index.ts";
6
+ import { type HonoEnv, parseOtherApId } from "./conversations-helpers.ts";
7
+
8
+ const typing = new Hono<HonoEnv>();
9
+
10
+ typing.post("/user/:encodedApId/typing", async (c) => {
11
+ const actor = c.get("actor");
12
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
13
+ const db = c.get("db");
14
+
15
+ const otherApId = parseOtherApId(c);
16
+ if (!otherApId) return c.json({ error: "ap_id required" }, 400);
17
+
18
+ const now = new Date().toISOString();
19
+ await db
20
+ .insert(dmTyping)
21
+ .values({
22
+ actorApId: actor.ap_id,
23
+ recipientApId: otherApId,
24
+ lastTypedAt: now,
25
+ })
26
+ .onConflictDoUpdate({
27
+ target: [dmTyping.actorApId, dmTyping.recipientApId],
28
+ set: { lastTypedAt: now },
29
+ });
30
+
31
+ return c.json({ success: true, typed_at: now });
32
+ });
33
+
34
+ typing.get("/user/:encodedApId/typing", async (c) => {
35
+ const actor = c.get("actor");
36
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
37
+ const db = c.get("db");
38
+
39
+ const otherApId = parseOtherApId(c);
40
+ if (!otherApId) return c.json({ error: "ap_id required" }, 400);
41
+
42
+ const typingRecord = await db
43
+ .select({ lastTypedAt: dmTyping.lastTypedAt })
44
+ .from(dmTyping)
45
+ .where(
46
+ and(
47
+ eq(dmTyping.actorApId, otherApId),
48
+ eq(dmTyping.recipientApId, actor.ap_id),
49
+ ),
50
+ )
51
+ .get();
52
+
53
+ if (!typingRecord?.lastTypedAt) {
54
+ return c.json({ is_typing: false, last_typed_at: null });
55
+ }
56
+
57
+ const lastTypedMs = Date.parse(typingRecord.lastTypedAt);
58
+ const elapsedMs = Date.now() - lastTypedMs;
59
+ const isValid = Number.isFinite(lastTypedMs);
60
+ const isTyping = isValid && elapsedMs <= 8000;
61
+ const isExpired = !isValid || elapsedMs > 5 * 60 * 1000;
62
+
63
+ if (isExpired) {
64
+ await db
65
+ .delete(dmTyping)
66
+ .where(
67
+ and(
68
+ eq(dmTyping.actorApId, otherApId),
69
+ eq(dmTyping.recipientApId, actor.ap_id),
70
+ ),
71
+ );
72
+ return c.json({ is_typing: false, last_typed_at: null });
73
+ }
74
+
75
+ return c.json({
76
+ is_typing: isTyping,
77
+ last_typed_at: typingRecord.lastTypedAt,
78
+ });
79
+ });
80
+
81
+ export default typing;
@@ -0,0 +1,15 @@
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 type { Env, Variables } from "../types.ts";
7
+ import conversationRoutes from "./dm/conversations.ts";
8
+ import messageRoutes from "./dm/messages.ts";
9
+
10
+ const dm = new Hono<{ Bindings: Env; Variables: Variables }>();
11
+
12
+ dm.route("/", conversationRoutes);
13
+ dm.route("/", messageRoutes);
14
+
15
+ export default dm;