Skip to content
File

Blob: src/worker/routes/search.ts

typescript93 lines
1import { Hono } from "hono";
2import { inArray } from "drizzle-orm";
3 
4import type { AppContext } from "@/worker/app-context";
5import { pages } from "@/worker/db/d1/schema";
6import { requireAuth } from "@/worker/middleware/auth";
7import { rateLimit } from "@/worker/middleware/rate-limit";
8import { checkMembership } from "@/worker/lib/membership";
9import { canAccessPages } from "@/worker/lib/permissions";
10import { sanitizeSnippet } from "@/worker/lib/html";
11import { createLogger } from "@/worker/lib/logger";
12 
13const log = createLogger("search");
14 
15const MAX_RESULTS = 20;
16 
17export const searchRouter = new Hono<AppContext>();
18 
19// GET /workspaces/:wid/search?q=... - Full-text search
20searchRouter.get("/workspaces/:wid/search", requireAuth, rateLimit("RL_API"), async (c) => {
21 const workspaceId = c.req.param("wid");
22 const user = c.get("user")!;
23 const db = c.get("db");
24 const query = c.req.query("q")?.trim();
25 
26 if (!query || query.length < 3) {
27 return c.json({ results: [] });
28 }
29 
30 const membership = await checkMembership(db, user.id, workspaceId);
31 // Guests and non-members with page-level shares get post-filtered results
32 const needsFilter = !membership || membership.role === "guest";
33 
34 // Overfetch from WorkspaceIndexer to compensate for post-filtering
35 const overfetchLimit = needsFilter ? 100 : 50;
36 
37 const indexer = c.env.WorkspaceIndexer.getByName(workspaceId);
38 const searchResult = await indexer.search(query, overfetchLimit);
39 
40 if (searchResult.items.length === 0) {
41 return c.json({ results: [] });
42 }
43 
44 const pageIds = searchResult.items.map((item) => item.pageId);
45 
46 // Load page metadata from D1 (title, icon, archived_at filtering)
47 const pageRows = await db
48 .select({ id: pages.id, title: pages.title, icon: pages.icon, archived_at: pages.archived_at })
49 .from(pages)
50 .where(inArray(pages.id, pageIds));
51 
52 // Index by page ID and filter archived pages
53 const pageById = new Map(pageRows.filter((p) => !p.archived_at).map((p) => [p.id, p]));
54 
55 // Reconstruct results preserving DO rank order
56 let results = searchResult.items
57 .filter((item) => pageById.has(item.pageId))
58 .map((item) => {
59 const page = pageById.get(item.pageId)!;
60 return {
61 page_id: item.pageId,
62 title: page.title,
63 icon: page.icon,
64 snippet: sanitizeSnippet(item.snippet),
65 };
66 });
67 
68 // Post-filter by canAccess for guests
69 if (needsFilter) {
70 const accessByPage = await canAccessPages(
71 db,
72 { type: "user", userId: user.id },
73 results.map((result) => result.page_id),
74 workspaceId,
75 "view",
76 );
77 const filtered = [];
78 for (const r of results) {
79 if (accessByPage.get(r.page_id)) {
80 filtered.push(r);
81 if (filtered.length >= MAX_RESULTS) break;
82 }
83 }
84 results = filtered;
85 } else {
86 results = results.slice(0, MAX_RESULTS);
87 }
88 
89 log.debug("search_executed", { workspaceId, query, resultCount: results.length });
90 
91 return c.json({ results });
92});