| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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}><!\\[CDATA\\[(.*?)\\]\\]><\\/${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", "<em>");
- url.searchParams.set("post_highlight_tag", "</em>");
- 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 };
- },
- };
- }
|