import { ApiError, badRequest } from "@/lib/api/errors"; import { decodeCursor, encodeCursor } from "@/lib/search/cursor"; import { buildQsirchQuery } from "@/lib/search/queryBuilder"; import { mapQsirchItemToSearchItem } from "@/lib/search/pathMapping"; /** * Qsirch provider (sync-first). * * Auth model: * - Qsirch requests are authenticated using QTS session cookies (NAS_SID / NAS_USER). * - We obtain NAS_SID via the QTS auth endpoint: * /cgi-bin/authLogin.cgi?user=...&pwd=...&serviceKey=1&... * * Notes: * - We cache NAS_SID in-memory and refresh on 401 responses. * - We do NOT expose Qsirch/QTS internals to API clients. */ function isBlank(v) { return v === undefined || v === null || String(v).trim() === ""; } function normalizeBaseUrl(baseUrl) { const raw = String(baseUrl || "").trim(); if (!raw) return null; // Basic normalization: remove trailing slash. return raw.endsWith("/") ? raw.slice(0, -1) : raw; } function encodePasswordForQts(password) { // QNAP docs mention an "ezEncode" step; the examples show base64 encoding. // We encode UTF-8 bytes as base64. return Buffer.from(String(password), "utf8").toString("base64"); } function extractXmlTagValue(xml, tag) { const re1 = new RegExp(`<${tag}><\\/${tag}>`, "i"); const re2 = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "i"); const m1 = String(xml).match(re1); if (m1 && m1[1]) return m1[1].trim(); const m2 = String(xml).match(re2); if (m2 && m2[1]) return m2[1].trim(); return null; } async function qtsLogin({ baseUrl, account, password, fetchImpl }) { const url = new URL(`${baseUrl}/cgi-bin/authLogin.cgi`); url.searchParams.set("user", account); url.searchParams.set("pwd", encodePasswordForQts(password)); url.searchParams.set("serviceKey", "1"); url.searchParams.set("remme", "0"); // Random param often used by QNAP examples to avoid caching. url.searchParams.set("r", String(Math.random())); let res; try { res = await fetchImpl(url.toString(), { method: "GET", headers: { Accept: "text/xml, application/xml, text/plain, */*" }, cache: "no-store", }); } catch (err) { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", cause: err, }); } const text = await res.text().catch(() => ""); // QTS auth endpoint typically returns 200 even for failed logins // and indicates the outcome in the XML body. const authSid = extractXmlTagValue(text, "authSid"); if (!authSid) { const errorValue = extractXmlTagValue(text, "errorValue"); throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", details: errorValue ? { errorValue } : undefined, }); } return authSid; } function buildCookieHeader({ account, sid }) { // Minimal cookies required for many QTS-protected requests. // Additional cookies may exist in browser sessions, but NAS_USER+NAS_SID // is typically sufficient for server-to-server calls. return `NAS_USER=${account}; NAS_SID=${sid}`; } function buildSnippet(content, q) { if (typeof content !== "string") return undefined; // Collapse whitespace for stable UI rendering. const text = content.replace(/\s+/g, " ").trim(); if (!text) return undefined; // If we have a query term, attempt to center the snippet around it. const needle = typeof q === "string" && q.trim() ? q.trim().toLowerCase() : null; const MAX = 240; if (!needle) { return text.length > MAX ? `${text.slice(0, MAX)}…` : text; } const hay = text.toLowerCase(); const idx = hay.indexOf(needle); if (idx === -1) { return text.length > MAX ? `${text.slice(0, MAX)}…` : text; } const start = Math.max(0, idx - 80); const end = Math.min(text.length, start + MAX); const chunk = text.slice(start, end).trim(); if (!chunk) return undefined; return (start > 0 ? "…" : "") + chunk + (end < text.length ? "…" : ""); } export function createQsirchProvider({ baseUrl, account, password, pathPrefix, dateField = "modified", mode = "sync", }) { const base = normalizeBaseUrl(baseUrl); if (!base || isBlank(account) || isBlank(password) || isBlank(pathPrefix)) { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", }); } let cachedSid = null; let sidPromise = null; async function getSid(fetchImpl) { if (cachedSid) return cachedSid; if (sidPromise) return sidPromise; sidPromise = (async () => { const sid = await qtsLogin({ baseUrl: base, account, password, fetchImpl, }); cachedSid = sid; return sid; })(); try { return await sidPromise; } finally { sidPromise = null; } } async function qsirchSearchOnce({ fetchImpl, sid, q, limit, offset }) { const url = new URL(`${base}/qsirch/latest/api/search/`); // Required url.searchParams.set("q", q); // Pagination url.searchParams.set("limit", String(limit)); url.searchParams.set("offset", String(offset)); // Avoid heavy/irrelevant data url.searchParams.set("show_folder", "0"); // files only url.searchParams.set("show_hidden", "0"); url.searchParams.set("show_absolute_path", "0"); // share path (not physical) url.searchParams.set("store_history", "0"); // do not store query history url.searchParams.set("tools", "0"); url.searchParams.set("tools_resp", "1"); url.searchParams.set("tools_limit_items", "50000"); // Permission checks on QTS side (defense-in-depth) url.searchParams.set("file_status", "1"); // We keep highlight params default-compatible but do not rely on them. url.searchParams.set("pre_highlight_tag", ""); url.searchParams.set("post_highlight_tag", ""); url.searchParams.set("highlight_limit", "250"); const headers = { accept: "application/json", cookie: buildCookieHeader({ account, sid }), }; const res = await fetchImpl(url.toString(), { method: "GET", headers, cache: "no-store", }); return res; } async function qsirchSearch({ fetchImpl, q, limit, offset }) { let sid = await getSid(fetchImpl); // Try once with the cached sid. let res; try { res = await qsirchSearchOnce({ fetchImpl, sid, q, limit, offset }); } catch (err) { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", cause: err, }); } // If SID expired, clear and retry once with a fresh SID. if (res.status === 401) { cachedSid = null; sid = await getSid(fetchImpl); res = await qsirchSearchOnce({ fetchImpl, sid, q, limit, offset }); } if (!res.ok) { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", }); } let payload; try { payload = await res.json(); } catch (err) { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", cause: err, }); } return payload; } return { /** * @param {{ * mode: "branch"|"multi"|"all", * branches: string[]|null, * q: string|null, * from: string|null, * to: string|null, * limit: number, * cursor: string|null * }} input */ async search(input) { const fetchImpl = input?.fetchImpl || fetch; // We support sync now; async will be added later without changing the public API. // "auto" currently behaves like "sync" to keep behavior predictable. const effectiveMode = mode === "async" ? "async" : "sync"; if (effectiveMode !== "sync") { throw new ApiError({ status: 500, code: "SEARCH_BACKEND_UNAVAILABLE", message: "Internal server error", }); } const { mode: searchMode, branches, q, from, to, limit } = input || {}; if (!Number.isInteger(limit) || limit < 1) { throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter"); } const decoded = decodeCursor(input?.cursor || null); if (decoded.mode !== "sync") { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } const offset = decoded.offset; const qsirchQ = buildQsirchQuery({ mode: searchMode, branches: branches || null, q, from, to, dateField, pathPrefix, }); const payload = await qsirchSearch({ fetchImpl, q: qsirchQ, limit, offset, }); const total = typeof payload?.total === "number" ? payload.total : Number(payload?.total); const rawItems = Array.isArray(payload?.items) ? payload.items : []; const items = rawItems .map((it) => { const mapped = mapQsirchItemToSearchItem(it, { pathPrefix }); if (!mapped) return null; const snippet = buildSnippet(it?.content, q); const result = { branch: mapped.branch, date: mapped.date, year: mapped.year, month: mapped.month, day: mapped.day, filename: mapped.filename, relativePath: mapped.relativePath, }; if (typeof it?.score === "number") result.score = it.score; if (snippet) result.snippet = snippet; return result; }) .filter(Boolean); // Pagination: // Use rawItems.length (not mapped length) to avoid repeating pages if we drop items. const rawCount = rawItems.length; const hasMore = Number.isFinite(total) && rawCount > 0 && offset + rawCount < total; const nextCursor = hasMore ? encodeCursor({ v: 1, mode: "sync", offset: offset + rawCount }) : null; return { items, nextCursor }; }, }; }