@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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;
|