/** * Qsirch query builder. * * We build a Qsirch "q" string using documented operators like: * - path:"/Public" * - modified:"YYYY-MM-DD" * - modified:"YYYY-MM-DD..YYYY-MM-DD" * - comparison operators: modified:>=YYYY-MM-DD * - extension:"pdf" * * Note: * - Qsirch operator syntax must not include spaces between operator and value. * (Example: name:"QNAP" is correct, name: QNAP is incorrect.) * * Security: * - We treat user input as plain search terms. * - We strip characters that could turn user input into Qsirch operators. */ /** * Normalize and sanitize user query so it cannot inject Qsirch operators. * * @param {string|null} raw * @returns {string|null} */ export function sanitizeUserQuery(raw) { if (typeof raw !== "string") return null; let s = raw.trim(); if (!s) return null; // Prevent operator injection: // - ":" is used by Qsirch operators (path:, modified:, extension:, ...) // - quotes can shape operator values s = s.replace(/[:"]/g, " "); // Prevent the user query from interfering with our own OR chaining: // We only remove the standalone token "OR" (case-sensitive), // so normal words like "order" or German "oder" remain unaffected. s = s.replace(/\bOR\b/g, " "); // Normalize whitespace s = s.replace(/\s+/g, " ").trim(); return s || null; } function normalizePathPrefix(prefix) { let p = String(prefix || "").trim(); if (!p) return "/"; // Ensure leading slash and no trailing slash (unless it's just "/"). if (!p.startsWith("/")) p = `/${p}`; if (p.length > 1) p = p.replace(/\/+$/, ""); return p; } function buildDateClause(dateField, from, to) { const f = String(dateField || "modified").trim(); // Range is the most explicit and is documented by QNAP for date ranges. if (from && to) return `${f}:"${from}..${to}"`; // QNAP documents comparison operators for dates/sizes as well. // Example: modified:<2015 (year) // We use ISO date strings here for determinism. if (from) return `${f}:>=${from}`; if (to) return `${f}:<=${to}`; return null; } function buildBranchClause({ pathPrefix, branch }) { const prefix = normalizePathPrefix(pathPrefix); return `path:"${prefix}/${branch}"`; } function buildGlobalClause({ pathPrefix }) { const prefix = normalizePathPrefix(pathPrefix); return `path:"${prefix}"`; } /** * Build the Qsirch "q" string from normalized inputs. * * @param {{ * mode: "branch"|"multi"|"all", * branches: string[]|null, * q: string|null, * from: string|null, * to: string|null, * dateField: "modified"|"created", * pathPrefix: string * }} input * @returns {string} */ export function buildQsirchQuery({ mode, branches, q, from, to, dateField, pathPrefix, }) { const userTerms = sanitizeUserQuery(q); const dateClause = buildDateClause(dateField, from, to); const extClause = `extension:"pdf"`; // Base terms that should always apply within a clause. // Order does not matter for AND semantics, but keeping a stable ordering // makes debugging and tests easier. function assembleClause(pathClause) { const parts = []; if (userTerms) parts.push(userTerms); parts.push(pathClause); parts.push(extClause); if (dateClause) parts.push(dateClause); return parts.join(" "); } if (mode === "all") { return assembleClause(buildGlobalClause({ pathPrefix })); } if (mode === "branch") { const b = branches?.[0]; return assembleClause(buildBranchClause({ pathPrefix, branch: b })); } // mode === "multi" // We replicate the full clause per branch and connect with OR. // This avoids precedence issues where a shared "extension:" or "modified:" // might only apply to the last OR segment. const clauses = (branches || []).map((b) => assembleClause(buildBranchClause({ pathPrefix, branch: b })) ); return clauses.join(" OR "); }