|
|
@@ -0,0 +1,368 @@
|
|
|
+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 };
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|