qsirch.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { ApiError, badRequest } from "@/lib/api/errors";
  2. import { decodeCursor, encodeCursor } from "@/lib/search/cursor";
  3. import { buildQsirchQuery } from "@/lib/search/queryBuilder";
  4. import { mapQsirchItemToSearchItem } from "@/lib/search/pathMapping";
  5. /**
  6. * Qsirch provider (sync-first).
  7. *
  8. * Auth model:
  9. * - Qsirch requests are authenticated using QTS session cookies (NAS_SID / NAS_USER).
  10. * - We obtain NAS_SID via the QTS auth endpoint:
  11. * /cgi-bin/authLogin.cgi?user=...&pwd=...&serviceKey=1&...
  12. *
  13. * Notes:
  14. * - We cache NAS_SID in-memory and refresh on 401 responses.
  15. * - We do NOT expose Qsirch/QTS internals to API clients.
  16. */
  17. function isBlank(v) {
  18. return v === undefined || v === null || String(v).trim() === "";
  19. }
  20. function normalizeBaseUrl(baseUrl) {
  21. const raw = String(baseUrl || "").trim();
  22. if (!raw) return null;
  23. // Basic normalization: remove trailing slash.
  24. return raw.endsWith("/") ? raw.slice(0, -1) : raw;
  25. }
  26. function encodePasswordForQts(password) {
  27. // QNAP docs mention an "ezEncode" step; the examples show base64 encoding.
  28. // We encode UTF-8 bytes as base64.
  29. return Buffer.from(String(password), "utf8").toString("base64");
  30. }
  31. function extractXmlTagValue(xml, tag) {
  32. const re1 = new RegExp(`<${tag}><!\\[CDATA\\[(.*?)\\]\\]><\\/${tag}>`, "i");
  33. const re2 = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "i");
  34. const m1 = String(xml).match(re1);
  35. if (m1 && m1[1]) return m1[1].trim();
  36. const m2 = String(xml).match(re2);
  37. if (m2 && m2[1]) return m2[1].trim();
  38. return null;
  39. }
  40. async function qtsLogin({ baseUrl, account, password, fetchImpl }) {
  41. const url = new URL(`${baseUrl}/cgi-bin/authLogin.cgi`);
  42. url.searchParams.set("user", account);
  43. url.searchParams.set("pwd", encodePasswordForQts(password));
  44. url.searchParams.set("serviceKey", "1");
  45. url.searchParams.set("remme", "0");
  46. // Random param often used by QNAP examples to avoid caching.
  47. url.searchParams.set("r", String(Math.random()));
  48. let res;
  49. try {
  50. res = await fetchImpl(url.toString(), {
  51. method: "GET",
  52. headers: { Accept: "text/xml, application/xml, text/plain, */*" },
  53. cache: "no-store",
  54. });
  55. } catch (err) {
  56. throw new ApiError({
  57. status: 500,
  58. code: "SEARCH_BACKEND_UNAVAILABLE",
  59. message: "Internal server error",
  60. cause: err,
  61. });
  62. }
  63. const text = await res.text().catch(() => "");
  64. // QTS auth endpoint typically returns 200 even for failed logins
  65. // and indicates the outcome in the XML body.
  66. const authSid = extractXmlTagValue(text, "authSid");
  67. if (!authSid) {
  68. const errorValue = extractXmlTagValue(text, "errorValue");
  69. throw new ApiError({
  70. status: 500,
  71. code: "SEARCH_BACKEND_UNAVAILABLE",
  72. message: "Internal server error",
  73. details: errorValue ? { errorValue } : undefined,
  74. });
  75. }
  76. return authSid;
  77. }
  78. function buildCookieHeader({ account, sid }) {
  79. // Minimal cookies required for many QTS-protected requests.
  80. // Additional cookies may exist in browser sessions, but NAS_USER+NAS_SID
  81. // is typically sufficient for server-to-server calls.
  82. return `NAS_USER=${account}; NAS_SID=${sid}`;
  83. }
  84. function buildSnippet(content, q) {
  85. if (typeof content !== "string") return undefined;
  86. // Collapse whitespace for stable UI rendering.
  87. const text = content.replace(/\s+/g, " ").trim();
  88. if (!text) return undefined;
  89. // If we have a query term, attempt to center the snippet around it.
  90. const needle =
  91. typeof q === "string" && q.trim() ? q.trim().toLowerCase() : null;
  92. const MAX = 240;
  93. if (!needle) {
  94. return text.length > MAX ? `${text.slice(0, MAX)}…` : text;
  95. }
  96. const hay = text.toLowerCase();
  97. const idx = hay.indexOf(needle);
  98. if (idx === -1) {
  99. return text.length > MAX ? `${text.slice(0, MAX)}…` : text;
  100. }
  101. const start = Math.max(0, idx - 80);
  102. const end = Math.min(text.length, start + MAX);
  103. const chunk = text.slice(start, end).trim();
  104. if (!chunk) return undefined;
  105. return (start > 0 ? "…" : "") + chunk + (end < text.length ? "…" : "");
  106. }
  107. export function createQsirchProvider({
  108. baseUrl,
  109. account,
  110. password,
  111. pathPrefix,
  112. dateField = "modified",
  113. mode = "sync",
  114. }) {
  115. const base = normalizeBaseUrl(baseUrl);
  116. if (!base || isBlank(account) || isBlank(password) || isBlank(pathPrefix)) {
  117. throw new ApiError({
  118. status: 500,
  119. code: "SEARCH_BACKEND_UNAVAILABLE",
  120. message: "Internal server error",
  121. });
  122. }
  123. let cachedSid = null;
  124. let sidPromise = null;
  125. async function getSid(fetchImpl) {
  126. if (cachedSid) return cachedSid;
  127. if (sidPromise) return sidPromise;
  128. sidPromise = (async () => {
  129. const sid = await qtsLogin({
  130. baseUrl: base,
  131. account,
  132. password,
  133. fetchImpl,
  134. });
  135. cachedSid = sid;
  136. return sid;
  137. })();
  138. try {
  139. return await sidPromise;
  140. } finally {
  141. sidPromise = null;
  142. }
  143. }
  144. async function qsirchSearchOnce({ fetchImpl, sid, q, limit, offset }) {
  145. const url = new URL(`${base}/qsirch/latest/api/search/`);
  146. // Required
  147. url.searchParams.set("q", q);
  148. // Pagination
  149. url.searchParams.set("limit", String(limit));
  150. url.searchParams.set("offset", String(offset));
  151. // Avoid heavy/irrelevant data
  152. url.searchParams.set("show_folder", "0"); // files only
  153. url.searchParams.set("show_hidden", "0");
  154. url.searchParams.set("show_absolute_path", "0"); // share path (not physical)
  155. url.searchParams.set("store_history", "0"); // do not store query history
  156. url.searchParams.set("tools", "0");
  157. url.searchParams.set("tools_resp", "1");
  158. url.searchParams.set("tools_limit_items", "50000");
  159. // Permission checks on QTS side (defense-in-depth)
  160. url.searchParams.set("file_status", "1");
  161. // We keep highlight params default-compatible but do not rely on them.
  162. url.searchParams.set("pre_highlight_tag", "<em>");
  163. url.searchParams.set("post_highlight_tag", "</em>");
  164. url.searchParams.set("highlight_limit", "250");
  165. const headers = {
  166. accept: "application/json",
  167. cookie: buildCookieHeader({ account, sid }),
  168. };
  169. const res = await fetchImpl(url.toString(), {
  170. method: "GET",
  171. headers,
  172. cache: "no-store",
  173. });
  174. return res;
  175. }
  176. async function qsirchSearch({ fetchImpl, q, limit, offset }) {
  177. let sid = await getSid(fetchImpl);
  178. // Try once with the cached sid.
  179. let res;
  180. try {
  181. res = await qsirchSearchOnce({ fetchImpl, sid, q, limit, offset });
  182. } catch (err) {
  183. throw new ApiError({
  184. status: 500,
  185. code: "SEARCH_BACKEND_UNAVAILABLE",
  186. message: "Internal server error",
  187. cause: err,
  188. });
  189. }
  190. // If SID expired, clear and retry once with a fresh SID.
  191. if (res.status === 401) {
  192. cachedSid = null;
  193. sid = await getSid(fetchImpl);
  194. res = await qsirchSearchOnce({ fetchImpl, sid, q, limit, offset });
  195. }
  196. if (!res.ok) {
  197. throw new ApiError({
  198. status: 500,
  199. code: "SEARCH_BACKEND_UNAVAILABLE",
  200. message: "Internal server error",
  201. });
  202. }
  203. let payload;
  204. try {
  205. payload = await res.json();
  206. } catch (err) {
  207. throw new ApiError({
  208. status: 500,
  209. code: "SEARCH_BACKEND_UNAVAILABLE",
  210. message: "Internal server error",
  211. cause: err,
  212. });
  213. }
  214. return payload;
  215. }
  216. return {
  217. /**
  218. * @param {{
  219. * mode: "branch"|"multi"|"all",
  220. * branches: string[]|null,
  221. * q: string|null,
  222. * from: string|null,
  223. * to: string|null,
  224. * limit: number,
  225. * cursor: string|null
  226. * }} input
  227. */
  228. async search(input) {
  229. const fetchImpl = input?.fetchImpl || fetch;
  230. // We support sync now; async will be added later without changing the public API.
  231. // "auto" currently behaves like "sync" to keep behavior predictable.
  232. const effectiveMode = mode === "async" ? "async" : "sync";
  233. if (effectiveMode !== "sync") {
  234. throw new ApiError({
  235. status: 500,
  236. code: "SEARCH_BACKEND_UNAVAILABLE",
  237. message: "Internal server error",
  238. });
  239. }
  240. const { mode: searchMode, branches, q, from, to, limit } = input || {};
  241. if (!Number.isInteger(limit) || limit < 1) {
  242. throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter");
  243. }
  244. const decoded = decodeCursor(input?.cursor || null);
  245. if (decoded.mode !== "sync") {
  246. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  247. }
  248. const offset = decoded.offset;
  249. const qsirchQ = buildQsirchQuery({
  250. mode: searchMode,
  251. branches: branches || null,
  252. q,
  253. from,
  254. to,
  255. dateField,
  256. pathPrefix,
  257. });
  258. const payload = await qsirchSearch({
  259. fetchImpl,
  260. q: qsirchQ,
  261. limit,
  262. offset,
  263. });
  264. const total =
  265. typeof payload?.total === "number"
  266. ? payload.total
  267. : Number(payload?.total);
  268. const rawItems = Array.isArray(payload?.items) ? payload.items : [];
  269. const items = rawItems
  270. .map((it) => {
  271. const mapped = mapQsirchItemToSearchItem(it, { pathPrefix });
  272. if (!mapped) return null;
  273. const snippet = buildSnippet(it?.content, q);
  274. const result = {
  275. branch: mapped.branch,
  276. date: mapped.date,
  277. year: mapped.year,
  278. month: mapped.month,
  279. day: mapped.day,
  280. filename: mapped.filename,
  281. relativePath: mapped.relativePath,
  282. };
  283. if (typeof it?.score === "number") result.score = it.score;
  284. if (snippet) result.snippet = snippet;
  285. return result;
  286. })
  287. .filter(Boolean);
  288. // Pagination:
  289. // Use rawItems.length (not mapped length) to avoid repeating pages if we drop items.
  290. const rawCount = rawItems.length;
  291. const hasMore =
  292. Number.isFinite(total) && rawCount > 0 && offset + rawCount < total;
  293. const nextCursor = hasMore
  294. ? encodeCursor({ v: 1, mode: "sync", offset: offset + rawCount })
  295. : null;
  296. return {
  297. items,
  298. nextCursor,
  299. total: Number.isFinite(total) ? total : null,
  300. };
  301. },
  302. };
  303. }