|
@@ -0,0 +1,141 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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 ");
|
|
|
|
|
+}
|