File
Blob: src/worker/routes/search.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { inArray } from "drizzle-orm"; |
| 3 | |
| 4 | import type { AppContext } from "@/worker/app-context"; |
| 5 | import { pages } from "@/worker/db/d1/schema"; |
| 6 | import { requireAuth } from "@/worker/middleware/auth"; |
| 7 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 8 | import { checkMembership } from "@/worker/lib/membership"; |
| 9 | import { canAccessPages } from "@/worker/lib/permissions"; |
| 10 | import { sanitizeSnippet } from "@/worker/lib/html"; |
| 11 | import { createLogger } from "@/worker/lib/logger"; |
| 12 | |
| 13 | const log = createLogger("search"); |
| 14 | |
| 15 | const MAX_RESULTS = 20; |
| 16 | |
| 17 | export const searchRouter = new Hono<AppContext>(); |
| 18 | |
| 19 | // GET /workspaces/:wid/search?q=... - Full-text search |
| 20 | searchRouter.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 | }); |