@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,730 @@
1
+ import { Hono } from "hono";
2
+ import { and, desc, eq, gt, inArray, isNull, like, or, sql } from "drizzle-orm";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import {
5
+ fetchWithTimeout,
6
+ formatUsername,
7
+ isSafeRemoteUrl,
8
+ normalizeRemoteDomain,
9
+ parseLimit,
10
+ parseOffset,
11
+ signRequest,
12
+ } from "../federation-helpers.ts";
13
+ import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
14
+ import type { Database } from "../../db/index.ts";
15
+ import {
16
+ actorCache,
17
+ actors,
18
+ likes,
19
+ notDeleted,
20
+ objects,
21
+ } from "../../db/index.ts";
22
+ import {
23
+ parseWebFinger,
24
+ tryParseRemoteActor,
25
+ } from "../lib/activitypub-validators.ts";
26
+ import { logger } from "../lib/logger.ts";
27
+ import { chunkForInClause } from "../lib/chunk.ts";
28
+ import { type ActorInfo, loadActorInfoMap } from "./actors-helpers.ts";
29
+ import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
30
+ import { withCache } from "../middleware/cache.ts";
31
+
32
+ const log = logger.child({ component: "search" });
33
+
34
+ // Trending hashtags are derived purely from public posts and carry no
35
+ // per-viewer data, so the response is identical for every caller. Cache it
36
+ // for 10 minutes to avoid re-scanning recent posts on every request.
37
+ const TRENDING_HASHTAGS_TTL = 600;
38
+ // Ceiling on the trending post scan. Hashtags are extracted from post CONTENT
39
+ // in JS (D1/SQLite has no REGEXP, and tags_json is only populated by local
40
+ // posts — federated inbound posts would be missed by a tags_json aggregation),
41
+ // so this bounds memory on a cache miss. It is a memory bound, NOT a silent
42
+ // window truncation: if a scan returns exactly this many rows we log that older
43
+ // in-window posts went uncounted. The scalable fix (an FTS / normalized-tags
44
+ // index aggregated in SQL) is tracked as deferred search work.
45
+ const TRENDING_SCAN_LIMIT = 2000;
46
+
47
+ const search = new Hono<{ Bindings: Env; Variables: Variables }>();
48
+ const REMOTE_FETCH_TIMEOUT_MS = 10000;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Sort validation
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const ALLOWED_ACTOR_SORTS = ["relevance", "followers", "recent"] as const;
55
+ type ActorSort = (typeof ALLOWED_ACTOR_SORTS)[number];
56
+
57
+ const ALLOWED_POST_SORTS = ["recent", "popular"] as const;
58
+ type PostSort = (typeof ALLOWED_POST_SORTS)[number];
59
+
60
+ function validateSort<T extends string>(
61
+ value: string | undefined,
62
+ allowed: readonly T[],
63
+ fallback: T,
64
+ ): T {
65
+ if (value && (allowed as readonly string[]).includes(value)) {
66
+ return value as T;
67
+ }
68
+ return fallback;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Shared types
73
+ // ---------------------------------------------------------------------------
74
+
75
+ // ActorInfo + the local-wins author lookup come from the canonical, D1-chunked
76
+ // loadActorInfoMap (actors-helpers). The previous file-local buildAuthorMap copy
77
+ // passed the (up to 100) author ids straight into inArray with no chunking — at
78
+ // D1's 100-bound-parameter ceiling with zero headroom — so it is removed in
79
+ // favour of the shared loader.
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Shared helpers (file-local, not exported)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Public-searchability guard shared by every anonymous-reachable post search
87
+ * (/posts, /hashtag, /hashtags/trending).
88
+ *
89
+ * Community-scoped Notes are persisted as visibility="public" but carry a
90
+ * non-"[]" audienceJson (the community read-gate). Filtering on visibility
91
+ * alone would leak private-community post content (and, via trending, tag
92
+ * names/counts) to anonymous or non-member callers. Both guards MUST be
93
+ * applied together; centralizing them here prevents the two conditions from
94
+ * drifting apart across the three search routes.
95
+ *
96
+ * Pass content/recency predicates as extra args; they are AND-ed with the
97
+ * public-scope guard.
98
+ */
99
+ function publicSearchableWhere(...extra: Parameters<typeof and>) {
100
+ return and(
101
+ // Search/trending surface POSTS only. Stories are stored as type='Story'
102
+ // with visibility='public' (their text lives in attachmentsJson, so content
103
+ // is empty today and they don't currently match), but gating on type keeps
104
+ // search consistent with the timeline + outbox and stops any non-Note object
105
+ // that ever carries content from leaking in.
106
+ eq(objects.type, "Note"),
107
+ eq(objects.visibility, "public"),
108
+ eq(objects.audienceJson, "[]"),
109
+ // Exclude soft-deleted/tombstoned objects, matching the takos-tools search
110
+ // path (which already filters isNull(deletedAt)). Objects are hard-deleted
111
+ // today so this is currently a no-op, but it keeps the two search surfaces
112
+ // consistent and future-proofs against any object soft-delete.
113
+ isNull(objects.deletedAt),
114
+ ...extra,
115
+ );
116
+ }
117
+
118
+ /** Build orderBy for post queries. A unique tiebreaker (`apId`) makes the sort
119
+ * TOTAL: `published` is a non-unique ISO-timestamp text (imported AP posts often
120
+ * share a whole second) and `likeCount` ties trivially, so without it SQLite
121
+ * gives no stable order for equal-key rows — across two independent OFFSET
122
+ * queries a tied row could shift into the already-consumed window and never be
123
+ * returned (the notifications keyset cursor uses a composite for the same
124
+ * reason). `apId` is the objects PK, so the ordering is unambiguous. */
125
+ function postOrderByDrizzle(sort: PostSort) {
126
+ if (sort === "popular") {
127
+ return [
128
+ desc(objects.likeCount),
129
+ desc(objects.published),
130
+ desc(objects.apId),
131
+ ];
132
+ }
133
+ return [desc(objects.published), desc(objects.apId)];
134
+ }
135
+
136
+ // Canonical hashtag tokenizer. Shared by trending and hashtag search so the two
137
+ // agree on what is a WHOLE hashtag token — a content `LIKE '%#tag%'` alone treats
138
+ // "#go" as matching "#golang" (a substring), which is wrong for both surfaces.
139
+ // The character class MUST match the one used by storage/federation
140
+ // (transformers.ts extractHashtags) and the web linkifier (post-tokens.ts), which
141
+ // both use full Unicode word chars (\p{L}\p{N}_). A narrower class here would let
142
+ // a non-CJK tag (Korean / Cyrillic / accented Latin / Greek / …) render as a link
143
+ // and federate but be un-findable by search/trending (and #café would mis-segment
144
+ // to #caf), silently diverging the three layers.
145
+ const HASHTAG_TOKEN_REGEX = /#([\p{L}\p{N}_]+)/gu;
146
+
147
+ /** Extract the lowercased hashtag tokens (without the leading '#') from content. */
148
+ function extractHashtags(content: string): string[] {
149
+ const tags: string[] = [];
150
+ HASHTAG_TOKEN_REGEX.lastIndex = 0;
151
+ let match: RegExpExecArray | null;
152
+ while ((match = HASHTAG_TOKEN_REGEX.exec(content)) !== null) {
153
+ tags.push(match[1].toLowerCase());
154
+ }
155
+ return tags;
156
+ }
157
+
158
+ // Ceiling on the hashtag-search candidate scan. The `LIKE '%#tag%'` prefilter
159
+ // returns a superset (substring matches); the exact whole-token filter then runs
160
+ // in JS (SQLite has no REGEXP). This bounds memory; if a scan hits the ceiling we
161
+ // log so deep results on a busy instance aren't silently dropped.
162
+ const HASHTAG_SEARCH_SCAN_CAP = 1000;
163
+ // Per-source (local + cached) row cap for actor search before the union/sort/
164
+ // slice. Generous for a single-user instance; bounds the in-memory merge.
165
+ const ACTOR_SEARCH_SCAN_CAP = 200;
166
+
167
+ // Trigram FTS5 indexes 3-grams, so it cannot match a query shorter than 3 chars.
168
+ const FTS_MIN_QUERY_LEN = 3;
169
+
170
+ /**
171
+ * Post-content search predicate (used by GET /search/posts).
172
+ *
173
+ * For queries >= 3 chars, match via the `objects_fts` trigram index (migration
174
+ * 0012) — an indexed substring search that works for Japanese, which the default
175
+ * tokenizer cannot segment. The user input is wrapped as an FTS5 phrase (double
176
+ * quoted, internal quotes doubled) so it is treated as a LITERAL substring rather
177
+ * than FTS query syntax (a stray `"` or `*` would otherwise change the match or
178
+ * error). Shorter queries fall back to LIKE since trigram cannot index them.
179
+ *
180
+ * The caller AND-s this with `publicSearchableWhere`, so visibility/audience
181
+ * gating still applies on top of the match (no private-post leak).
182
+ */
183
+ function likeContains(column: Parameters<typeof like>[0], value: string) {
184
+ // Match with instr() (a LITERAL substring search), lowercased for the same
185
+ // ASCII case-insensitivity LIKE gives — NOT `LIKE '%...%'`: a long search term
186
+ // (>~48 chars) trips D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500).
187
+ // instr() has no wildcards (so a user's literal `%`/`_` matches literally,
188
+ // which is what search wants) and no length limit.
189
+ return sql`instr(lower(${column}), lower(${value})) > 0`;
190
+ }
191
+
192
+ export function postContentSearchPredicate(query: string) {
193
+ // Count CODEPOINTS, not UTF-16 units: the trigram tokenizer needs >=3
194
+ // codepoints to form a single trigram, but `query.length` counts UTF-16 units,
195
+ // so a 2-emoji query ("🦀🦀") has .length 4 yet only 2 codepoints — it would
196
+ // route to FTS MATCH, form ZERO trigrams, and silently match nothing. Use the
197
+ // codepoint count so 1-2 codepoint queries fall back to instr() which matches.
198
+ if ([...query].length < FTS_MIN_QUERY_LEN) {
199
+ return likeContains(objects.content, query);
200
+ }
201
+ const phrase = '"' + query.replace(/"/g, '""') + '"';
202
+ return sql`objects.rowid IN (SELECT rowid FROM objects_fts WHERE objects_fts MATCH ${phrase})`;
203
+ }
204
+
205
+ /** Load the set of post AP IDs that a given actor has liked. */
206
+ async function loadLikedPostIds(
207
+ db: Database,
208
+ actorApId: string | undefined,
209
+ postApIds: string[],
210
+ ): Promise<Set<string>> {
211
+ if (!actorApId || postApIds.length === 0) return new Set();
212
+
213
+ // Chunk the IN(...) lookup: a full hashtag-search page is up to 100 post ids
214
+ // and D1 caps a query at 100 bound parameters (the extra eq() param pushes a
215
+ // 100-id IN over the edge). Chunks are disjoint id slices, so unioning the
216
+ // liked-id sets is collision-free.
217
+ const liked = new Set<string>();
218
+ for (const ids of chunkForInClause(postApIds)) {
219
+ const likeRows = await db
220
+ .select({ objectApId: likes.objectApId })
221
+ .from(likes)
222
+ .where(
223
+ and(eq(likes.actorApId, actorApId), inArray(likes.objectApId, ids)),
224
+ );
225
+ for (const l of likeRows) liked.add(l.objectApId);
226
+ }
227
+ return liked;
228
+ }
229
+
230
+ type PostRow = {
231
+ apId: string;
232
+ attributedTo: string;
233
+ content: string;
234
+ published: string | null;
235
+ likeCount: number;
236
+ };
237
+
238
+ /** Map a raw post + author map + liked set into the API response shape. */
239
+ function formatPost(
240
+ post: PostRow,
241
+ authorMap: Map<string, ActorInfo>,
242
+ likedPostIds: Set<string>,
243
+ ): {
244
+ ap_id: string;
245
+ author: {
246
+ ap_id: string;
247
+ username: string;
248
+ preferred_username: string | null;
249
+ name: string | null;
250
+ icon_url: string | null;
251
+ };
252
+ content: string;
253
+ published: string | null;
254
+ like_count: number;
255
+ liked: boolean;
256
+ } {
257
+ const author = authorMap.get(post.attributedTo);
258
+ return {
259
+ ap_id: post.apId,
260
+ author: {
261
+ ap_id: post.attributedTo,
262
+ username: formatUsername(post.attributedTo),
263
+ preferred_username: author?.preferredUsername ?? null,
264
+ name: author?.name ?? null,
265
+ icon_url: author?.iconUrl ?? null,
266
+ },
267
+ content: post.content,
268
+ published: post.published,
269
+ like_count: post.likeCount,
270
+ liked: likedPostIds.has(post.apId),
271
+ };
272
+ }
273
+
274
+ /** Enrich posts with author info and like status, returning formatted API results. */
275
+ async function enrichPosts(
276
+ db: Database,
277
+ posts: PostRow[],
278
+ actorApId: string | undefined,
279
+ ): Promise<ReturnType<typeof formatPost>[]> {
280
+ if (posts.length === 0) return [];
281
+
282
+ const authorApIds = [...new Set(posts.map((p) => p.attributedTo))];
283
+ const [authorMap, likedPostIds] = await Promise.all([
284
+ loadActorInfoMap(db, authorApIds, "author"),
285
+ loadLikedPostIds(
286
+ db,
287
+ actorApId,
288
+ posts.map((p) => p.apId),
289
+ ),
290
+ ]);
291
+
292
+ return posts.map((p) => formatPost(p, authorMap, likedPostIds));
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Routes
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Search local actors by username or name
301
+ * GET /api/search/actors?q=query&sort=relevance|followers|recent
302
+ */
303
+ search.get("/actors", async (c) => {
304
+ const rawQuery = c.req.query("q")?.trim();
305
+ if (!rawQuery) return c.json({ actors: [] });
306
+ // Users commonly type the leading "@" of a handle, but preferredUsername is
307
+ // stored without it — strip it so "@tako" finds "tako". (A full "@user@domain"
308
+ // handle is the remote-search path's job, not this local/cached lookup.)
309
+ const query = rawQuery.replace(/^@+/, "");
310
+ if (!query) return c.json({ actors: [] });
311
+
312
+ const db = c.get("db");
313
+ const sort = validateSort(
314
+ c.req.query("sort"),
315
+ ALLOWED_ACTOR_SORTS,
316
+ "relevance",
317
+ );
318
+ const limit = parseLimit(c.req.query("limit"), 20, 50);
319
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
320
+ const lowerQuery = query.toLowerCase();
321
+
322
+ const orderByClause =
323
+ sort === "recent" ? [desc(actors.createdAt)] : [desc(actors.followerCount)];
324
+
325
+ const [localRows, cachedRows] = await Promise.all([
326
+ db
327
+ .select({
328
+ apId: actors.apId,
329
+ preferredUsername: actors.preferredUsername,
330
+ name: actors.name,
331
+ iconUrl: actors.iconUrl,
332
+ summary: actors.summary,
333
+ followerCount: actors.followerCount,
334
+ createdAt: actors.createdAt,
335
+ })
336
+ .from(actors)
337
+ .where(
338
+ and(
339
+ notDeleted(actors),
340
+ // A private/locked account is discoverable:false + manuallyApproves
341
+ // followers; it must not surface in anonymous-reachable actor search
342
+ // (its existence/name/bio/follower-count would leak). Matches the
343
+ // takos-tools searchUsers + getUserProfile gates.
344
+ eq(actors.isPrivate, 0),
345
+ or(
346
+ likeContains(actors.preferredUsername, query),
347
+ likeContains(actors.name, query),
348
+ ),
349
+ ),
350
+ )
351
+ .orderBy(...orderByClause)
352
+ .limit(ACTOR_SEARCH_SCAN_CAP),
353
+ // Previously-discovered remote actors live in actorCache (populated by the
354
+ // /remote webfinger lookup). Consult it here so an account someone already
355
+ // found stays re-findable by name/username without re-typing the full
356
+ // handle. Cached actors have no local follower/created metadata.
357
+ db
358
+ .select({
359
+ apId: actorCache.apId,
360
+ preferredUsername: actorCache.preferredUsername,
361
+ name: actorCache.name,
362
+ iconUrl: actorCache.iconUrl,
363
+ summary: actorCache.summary,
364
+ })
365
+ .from(actorCache)
366
+ .where(
367
+ or(
368
+ likeContains(actorCache.preferredUsername, query),
369
+ likeContains(actorCache.name, query),
370
+ ),
371
+ )
372
+ .limit(ACTOR_SEARCH_SCAN_CAP),
373
+ ]);
374
+
375
+ // UNION local + cached, with local taking priority on apId collision.
376
+ const seen = new Set(localRows.map((a) => a.apId));
377
+ const actorRows: {
378
+ apId: string;
379
+ preferredUsername: string | null;
380
+ name: string | null;
381
+ iconUrl: string | null;
382
+ summary: string | null;
383
+ followerCount: number;
384
+ createdAt: string | null;
385
+ }[] = [
386
+ ...localRows,
387
+ ...cachedRows
388
+ .filter((a) => !seen.has(a.apId))
389
+ .map((a) => ({
390
+ apId: a.apId,
391
+ preferredUsername: a.preferredUsername,
392
+ name: a.name,
393
+ iconUrl: a.iconUrl,
394
+ summary: a.summary,
395
+ followerCount: 0,
396
+ createdAt: null,
397
+ })),
398
+ ];
399
+
400
+ if (sort === "relevance") {
401
+ actorRows.sort((a, b) => {
402
+ const aUsername = (a.preferredUsername ?? "").toLowerCase();
403
+ const bUsername = (b.preferredUsername ?? "").toLowerCase();
404
+
405
+ const aExact = aUsername === lowerQuery ? 0 : 1;
406
+ const bExact = bUsername === lowerQuery ? 0 : 1;
407
+ if (aExact !== bExact) return aExact - bExact;
408
+
409
+ const aPrefix = aUsername.startsWith(lowerQuery) ? 0 : 1;
410
+ const bPrefix = bUsername.startsWith(lowerQuery) ? 0 : 1;
411
+ if (aPrefix !== bPrefix) return aPrefix - bPrefix;
412
+
413
+ return b.followerCount - a.followerCount;
414
+ });
415
+ }
416
+
417
+ // Page the merged+sorted set in app code (the two underlying queries are each
418
+ // scan-capped, then unioned/sorted, so SQL LIMIT/OFFSET cannot paginate the
419
+ // combined result — same shape as the hashtag handler). has_more is exact up
420
+ // to the scan cap.
421
+ const total = actorRows.length;
422
+ const pageRows = actorRows.slice(offset, offset + limit);
423
+ const has_more = offset + pageRows.length < total;
424
+
425
+ const result = pageRows.map((a) => ({
426
+ ap_id: a.apId,
427
+ preferred_username: a.preferredUsername,
428
+ name: a.name,
429
+ icon_url: a.iconUrl,
430
+ summary: a.summary,
431
+ follower_count: a.followerCount,
432
+ created_at: a.createdAt,
433
+ username: formatUsername(a.apId),
434
+ }));
435
+
436
+ return c.json({ actors: result, limit, offset, has_more });
437
+ });
438
+
439
+ /**
440
+ * Search posts by content
441
+ * GET /api/search/posts?q=query&sort=recent|popular
442
+ */
443
+ search.get("/posts", async (c) => {
444
+ const query = c.req.query("q")?.trim();
445
+ if (!query) return c.json({ posts: [] });
446
+
447
+ const actor = c.get("actor");
448
+ const db = c.get("db");
449
+ const sort = validateSort(c.req.query("sort"), ALLOWED_POST_SORTS, "recent");
450
+ const limit = parseLimit(c.req.query("limit"), 20, 50);
451
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
452
+
453
+ // Fetch one extra past the page to report has_more without a COUNT.
454
+ const rows = await db
455
+ .select({
456
+ apId: objects.apId,
457
+ attributedTo: objects.attributedTo,
458
+ content: objects.content,
459
+ published: objects.published,
460
+ likeCount: objects.likeCount,
461
+ })
462
+ .from(objects)
463
+ .where(
464
+ publicSearchableWhere(
465
+ postContentSearchPredicate(query),
466
+ // Suppress blocked/muted authors so keyword search honors the same
467
+ // moderation filter the home/timeline/notifications feeds apply
468
+ // (undefined for an anonymous viewer → and() drops it).
469
+ excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
470
+ ),
471
+ )
472
+ .orderBy(...postOrderByDrizzle(sort))
473
+ .limit(limit + 1)
474
+ .offset(offset);
475
+
476
+ const has_more = rows.length > limit;
477
+ const posts = has_more ? rows.slice(0, limit) : rows;
478
+
479
+ return c.json({
480
+ posts: await enrichPosts(db, posts, actor?.ap_id),
481
+ limit,
482
+ offset,
483
+ has_more,
484
+ });
485
+ });
486
+
487
+ /**
488
+ * Search remote actors via WebFinger
489
+ * GET /api/search/remote?q=@user@domain
490
+ */
491
+ search.get("/remote", async (c) => {
492
+ const query = c.req.query("q")?.trim();
493
+ if (!query) return c.json({ actors: [] });
494
+
495
+ const match = query.match(/^@?([^@]+)@([^@]+)$/);
496
+ if (!match) return c.json({ actors: [] });
497
+
498
+ const [, username, domain] = match;
499
+ const safeDomain = normalizeRemoteDomain(domain);
500
+ if (!safeDomain) return c.json({ actors: [] });
501
+
502
+ try {
503
+ // WebFinger lookup
504
+ const webfingerUrl = `https://${safeDomain}/.well-known/webfinger?resource=acct:${username}@${safeDomain}`;
505
+ const wfRes = await fetchWithTimeout(webfingerUrl, {
506
+ headers: { Accept: "application/jrd+json" },
507
+ timeout: REMOTE_FETCH_TIMEOUT_MS,
508
+ });
509
+ if (!wfRes.ok) return c.json({ actors: [] });
510
+
511
+ const wfRaw: unknown = await wfRes.json();
512
+ let wfData;
513
+ try {
514
+ wfData = parseWebFinger(wfRaw);
515
+ } catch {
516
+ return c.json({ actors: [] });
517
+ }
518
+ const actorLink = wfData.links?.find(
519
+ (l) => l.rel === "self" && l.type === "application/activity+json",
520
+ );
521
+ if (!actorLink?.href || !isSafeRemoteUrl(actorLink.href)) {
522
+ return c.json({ actors: [] });
523
+ }
524
+
525
+ // Fetch actor profile. Sign the GET as the instance actor so a remote in
526
+ // authorized-fetch / secure mode (which 401s the unsigned actor GET while
527
+ // leaving webfinger public) still resolves — otherwise searching for an
528
+ // account on such an instance silently returns zero results.
529
+ const signer = await getInstanceFetchSigner(c);
530
+ const actorRes = await fetchWithTimeout(actorLink.href, {
531
+ headers: {
532
+ Accept: "application/activity+json, application/ld+json",
533
+ ...(await signRequest(
534
+ signer.privateKeyPem,
535
+ signer.keyId,
536
+ "GET",
537
+ actorLink.href,
538
+ )),
539
+ },
540
+ timeout: REMOTE_FETCH_TIMEOUT_MS,
541
+ });
542
+ if (!actorRes.ok) return c.json({ actors: [] });
543
+
544
+ const actorRaw: unknown = await actorRes.json();
545
+ const actorData = tryParseRemoteActor(actorRaw);
546
+ if (!actorData) return c.json({ actors: [] });
547
+
548
+ if (actorData.id !== actorLink.href || !isSafeRemoteUrl(actorData.id)) {
549
+ return c.json({ actors: [] });
550
+ }
551
+
552
+ // Cache the actor (upsert: check if exists, then insert or update)
553
+ const db = c.get("db");
554
+ const cacheFields = {
555
+ type: actorData.type || "Person",
556
+ preferredUsername: actorData.preferredUsername || null,
557
+ name: actorData.name || null,
558
+ summary: actorData.summary || null,
559
+ iconUrl: actorData.icon?.url || null,
560
+ inbox: actorData.inbox || "",
561
+ outbox: actorData.outbox || null,
562
+ publicKeyId: actorData.publicKey?.id || null,
563
+ publicKeyPem: actorData.publicKey?.publicKeyPem || null,
564
+ rawJson: JSON.stringify(actorRaw),
565
+ };
566
+
567
+ const existing = await db
568
+ .select({ apId: actorCache.apId })
569
+ .from(actorCache)
570
+ .where(eq(actorCache.apId, actorData.id))
571
+ .get();
572
+
573
+ if (existing) {
574
+ await db
575
+ .update(actorCache)
576
+ .set(cacheFields)
577
+ .where(eq(actorCache.apId, actorData.id));
578
+ } else {
579
+ await db.insert(actorCache).values({
580
+ apId: actorData.id,
581
+ ...cacheFields,
582
+ });
583
+ }
584
+
585
+ return c.json({
586
+ actors: [
587
+ {
588
+ ap_id: actorData.id,
589
+ username: `${actorData.preferredUsername}@${safeDomain}`,
590
+ preferred_username: actorData.preferredUsername,
591
+ name: actorData.name,
592
+ summary: actorData.summary,
593
+ icon_url: actorData.icon?.url,
594
+ },
595
+ ],
596
+ });
597
+ } catch (e) {
598
+ log.error("Remote search failed", {
599
+ event: "search.remote.failed",
600
+ error: e,
601
+ });
602
+ return c.json({ actors: [] });
603
+ }
604
+ });
605
+
606
+ /**
607
+ * Search posts by hashtag
608
+ * GET /api/search/hashtag/:tag?sort=recent|popular
609
+ */
610
+ search.get("/hashtag/:tag", async (c) => {
611
+ const tag = c.req.param("tag")?.trim().replace(/^#/, "");
612
+ if (!tag) return c.json({ posts: [], total: 0 });
613
+
614
+ const actor = c.get("actor");
615
+ const db = c.get("db");
616
+ const sort = validateSort(c.req.query("sort"), ALLOWED_POST_SORTS, "recent");
617
+ const limit = parseLimit(c.req.query("limit"), 50, 100);
618
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
619
+ const hashtagPattern = `#${tag}`;
620
+ const tagLower = tag.toLowerCase();
621
+
622
+ // instr() (literal, lowercased for case-insensitivity), NOT `LIKE '%...%'`: a
623
+ // long #tag would trip D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500).
624
+ // This is a SUPERSET prefilter; the exact #tag check below narrows it.
625
+ const postWhere = publicSearchableWhere(
626
+ sql`instr(lower(${objects.content}), lower(${hashtagPattern})) > 0`,
627
+ // Same block/mute moderation filter as the feeds (see /search/posts).
628
+ excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
629
+ );
630
+
631
+ // `LIKE '%#tag%'` is a SUPERSET prefilter: it also matches longer tags that
632
+ // share the prefix (searching "#deploy" would otherwise return "#deployed").
633
+ // SQLite has no REGEXP, so fetch the ordered candidates and keep only those
634
+ // whose content carries the tag as a WHOLE token (matching trending + the
635
+ // client linkifier). Filtering after the DB sort preserves recent/popular
636
+ // order; pagination + total are computed on the exact-matched set so they stay
637
+ // consistent. Content-based, so federated posts (no tags_json) are covered.
638
+ const candidates = await db
639
+ .select({
640
+ apId: objects.apId,
641
+ attributedTo: objects.attributedTo,
642
+ content: objects.content,
643
+ published: objects.published,
644
+ likeCount: objects.likeCount,
645
+ })
646
+ .from(objects)
647
+ .where(postWhere)
648
+ .orderBy(...postOrderByDrizzle(sort))
649
+ .limit(HASHTAG_SEARCH_SCAN_CAP);
650
+
651
+ if (candidates.length === HASHTAG_SEARCH_SCAN_CAP) {
652
+ log.warn("hashtag search hit candidate ceiling; deep results may be cut", {
653
+ event: "search.hashtag.truncated",
654
+ tag: tagLower,
655
+ });
656
+ }
657
+
658
+ const matched = candidates.filter((p) =>
659
+ extractHashtags(p.content || "").includes(tagLower),
660
+ );
661
+
662
+ const total = matched.length;
663
+ const pagePosts = matched.slice(offset, offset + limit);
664
+ const resultPosts = await enrichPosts(db, pagePosts, actor?.ap_id);
665
+
666
+ return c.json({
667
+ posts: resultPosts,
668
+ total,
669
+ limit,
670
+ offset,
671
+ has_more: offset + resultPosts.length < total,
672
+ });
673
+ });
674
+
675
+ /**
676
+ * Get trending hashtags
677
+ * GET /api/search/hashtags/trending?limit=10&days=7
678
+ */
679
+ search.get(
680
+ "/hashtags/trending",
681
+ withCache({
682
+ ttl: TRENDING_HASHTAGS_TTL,
683
+ queryParamsToInclude: ["limit", "days"],
684
+ }),
685
+ async (c) => {
686
+ const limit = parseLimit(c.req.query("limit"), 10, 50);
687
+ const days = parseLimit(c.req.query("days"), 7, 30);
688
+ const sinceDate = new Date(
689
+ Date.now() - days * 24 * 60 * 60 * 1000,
690
+ ).toISOString();
691
+
692
+ const db = c.get("db");
693
+
694
+ const posts = await db
695
+ .select({ content: objects.content })
696
+ .from(objects)
697
+ .where(publicSearchableWhere(gt(objects.published, sinceDate)))
698
+ .orderBy(desc(objects.published))
699
+ .limit(TRENDING_SCAN_LIMIT);
700
+
701
+ if (posts.length === TRENDING_SCAN_LIMIT) {
702
+ // No silent truncation: the requested `days` window held more public posts
703
+ // than the scan ceiling, so older in-window posts were not counted toward
704
+ // the trend. Surface it for operators on busy instances.
705
+ log.warn("trending scan hit ceiling; older in-window posts uncounted", {
706
+ event: "search.trending.truncated",
707
+ scanned: posts.length,
708
+ days,
709
+ });
710
+ }
711
+
712
+ // Extract and count hashtags (shared whole-token tokenizer keeps trending
713
+ // and hashtag search consistent on what counts as a tag).
714
+ const hashtagCounts: Record<string, number> = {};
715
+ for (const post of posts) {
716
+ for (const tagName of extractHashtags(post.content || "")) {
717
+ hashtagCounts[tagName] = (hashtagCounts[tagName] || 0) + 1;
718
+ }
719
+ }
720
+
721
+ const trending = Object.entries(hashtagCounts)
722
+ .sort((a, b) => b[1] - a[1])
723
+ .slice(0, limit)
724
+ .map(([tagName, count]) => ({ tag: tagName, count }));
725
+
726
+ return c.json({ trending });
727
+ },
728
+ );
729
+
730
+ export default search;