diff --git a/CHANGELOG.md b/CHANGELOG.md index 2590c26161..5c40a4d153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **MiMoCode free-tier provider** ([#3659] — thanks @pizzav-xyz): new no-auth provider `mimocode` (alias `mcode`) exposing Xiaomi's `mimo-auto` model (1M context) via device-fingerprint bootstrap-JWT auth (`/api/free-ai/bootstrap` → Bearer JWT → `/api/free-ai/openai/chat`). Supports multiple accounts (N fingerprints → round-robin with exponential cooldown), re-bootstrap on 401/403, and cooldown on 429. Reuses a new generic `NoAuthAccountCard` dashboard component (also wired for `opencode`). 22 unit tests; upstream validated live during review. (Maintainer follow-up: added the required `authHeader: "none"` field to the registry entry.) Co-authored with @pizzav-xyz. - **Prefer Claude Code for unprefixed `claude-*` model IDs** ([#3540] — thanks @Witroch4): opt-in setting (default off) that routes bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Configurable via the `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` env flag or a dashboard toggle on the Claude provider page; explicit provider prefixes still win. Full layer coverage (resolver + DB setting + zod schemas + types + UI) with 6 tests. Co-authored with @Witroch4. - **Codex Responses-WebSocket call history** ([#3616] — thanks @kkkayye): Codex `/v1/responses` WebSocket calls are now persisted to request history — success completions plus prepare-failures, upstream WS errors and premature closes — with `sanitizeErrorMessage` applied to the stored error. Two proxy-side integration tests cover the success and failure paths. +- **Obsidian/WebDAV**: add the `/api/v1/webdav` file server (PROPFIND/GET/PUT/DELETE/MKCOL/MOVE, Basic-Auth, path-traversal hardened) so Obsidian mobile can sync the vault (#3485, part 2). Implemented in the custom server layer (`scripts/dev/webdav-handler.mjs`) — intercepted before Next.js to support non-standard HTTP methods (`PROPFIND`, `MKCOL`, `MOVE`, `LOCK`). Reads vault path and credentials (with enc:v1: AES-256-GCM decryption) directly from the SQLite `key_value` table; credentials configured via PR1's `/api/settings/obsidian/webdav` endpoint. 36 TDD unit tests covering traversal guard, constant-time auth, decrypt round-trip, XML generation, and full CRUD cycle. ### ♻️ Code Quality diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index e7d4fb7672..b12f2093cf 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -2,6 +2,7 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; +import { maybeHandleWebdav } from "./webdav-handler.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); @@ -48,12 +49,32 @@ function wrapUpgradeListener(server, listener) { }; } +/** + * Wrap a request listener so WebDAV requests at /api/v1/webdav are handled + * before the peer-stamp/Next.js layer sees them. + * Returns true if the request was handled; the wrapped listener is never called. + */ +function wrapRequestListenerWithWebdav(listener) { + return async function webdavAwareRequestHandler(req, res) { + try { + const handled = await maybeHandleWebdav(req, res); + if (handled) return; + } catch { + // Never block a request on WebDAV errors — fall through to Next + } + return listener.call(this, req, res); + }; +} + http.createServer = function createServerWithResponsesWs(...args) { // Next's standalone server.js may pass its request listener directly to // createServer; wrap it so the real TCP peer IP is stamped before Next runs. const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true); if (lastFnIdx >= 0) { - args[lastFnIdx] = wrapRequestListenerWithPeerStamp(args[lastFnIdx]); + // WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next. + args[lastFnIdx] = wrapRequestListenerWithWebdav( + wrapRequestListenerWithPeerStamp(args[lastFnIdx]) + ); } const server = originalCreateServer(...args); @@ -66,7 +87,10 @@ http.createServer = function createServerWithResponsesWs(...args) { } // …or it may attach the handler via server.on("request"): wrap that too. if (eventName === "request" && typeof listener === "function") { - return originalOn(eventName, wrapRequestListenerWithPeerStamp(listener)); + return originalOn( + eventName, + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ); } return originalOn(eventName, listener); }; @@ -76,7 +100,10 @@ http.createServer = function createServerWithResponsesWs(...args) { return originalAddListener(eventName, wrapUpgradeListener(server, listener)); } if (eventName === "request" && typeof listener === "function") { - return originalAddListener(eventName, wrapRequestListenerWithPeerStamp(listener)); + return originalAddListener( + eventName, + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ); } return originalAddListener(eventName, listener); }; diff --git a/scripts/dev/webdav-handler.mjs b/scripts/dev/webdav-handler.mjs new file mode 100644 index 0000000000..0113cc1b65 --- /dev/null +++ b/scripts/dev/webdav-handler.mjs @@ -0,0 +1,1002 @@ +/** + * WebDAV file server handler for OmniRoute. + * + * Serves the Obsidian vault directory at /api/v1/webdav, enabling + * Obsidian mobile (Remotely-Save plugin) to sync over Tailscale. + * + * Architecture: + * - Pure logic functions (resolveVaultPath, verifyBasicAuth, buildPropfindXml, + * decryptStored) are exported and unit-tested independently. + * - maybeHandleWebdav(req, res) is the thin HTTP+fs binding layer. + * - Credentials, enabled flag, and vault path are loaded from the same + * SQLite DB the app uses (DATA_DIR/storage.sqlite, key_value table). + * + * Security: + * - Path traversal guard on every request + Destination header. + * - Basic Auth with crypto.timingSafeEqual (constant-time). + * - No raw fs paths or stack traces in error response bodies. + * - NOT local-only — reachable over Tailscale; auth is the gate. + * - PUT body capped at MAX_PUT_BYTES. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createDecipheriv, scryptSync, createHash, timingSafeEqual } from "node:crypto"; +import { createRequire } from "node:module"; +import os from "node:os"; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +export const WEBDAV_PREFIX = "/api/v1/webdav"; + +/** Cap PUT body to 512 MiB — Obsidian vaults are unlikely to need larger. */ +export const MAX_PUT_BYTES = 512 * 1024 * 1024; + +const WEBDAV_METHODS = new Set([ + "PROPFIND", + "GET", + "HEAD", + "PUT", + "DELETE", + "MKCOL", + "MOVE", + "COPY", + "OPTIONS", + "LOCK", + "UNLOCK", +]); + +// ───────────────────────────────────────────────────────────────────────────── +// Encryption: minimal port of src/lib/db/encryption.ts +// Must reproduce the EXACT format: enc:v1::: +// Key derivation: scryptSync(secret, "omniroute-field-encryption-v1", 32) +// ───────────────────────────────────────────────────────────────────────────── + +const ENC_PREFIX = "enc:v1:"; +const STATIC_SALT = "omniroute-field-encryption-v1"; +const KEY_LENGTH = 32; +const AUTH_TAG_LENGTH = 16; +const ALGORITHM = "aes-256-gcm"; + +let _cachedStaticKey = null; + +/** + * Derive the static-salt encryption key from STORAGE_ENCRYPTION_KEY. + * Returns null if the env var is not set (passthrough mode). + */ +function getStaticKey() { + if (_cachedStaticKey !== null) return _cachedStaticKey; + const secret = process.env.STORAGE_ENCRYPTION_KEY; + if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; + try { + _cachedStaticKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH); + } catch { + return null; + } + return _cachedStaticKey; +} + +/** + * Decrypt a value that may be encrypted with the OmniRoute enc:v1: format. + * If the value does not have the enc:v1: prefix, treat as plaintext (backward compat). + * Returns null if decryption fails. + * + * @param {string | null | undefined} stored + * @returns {string | null} + */ +export function decryptStored(stored) { + if (!stored || typeof stored !== "string") return null; + + // Plaintext passthrough (no encryption key configured, or legacy plaintext) + if (!stored.startsWith(ENC_PREFIX)) return stored; + + const key = getStaticKey(); + if (!key) { + // Encrypted value but no key — cannot decrypt + return null; + } + + const body = stored.slice(ENC_PREFIX.length); + const parts = body.split(":"); + if (parts.length !== 3) return null; + + const [ivHex, encryptedHex, authTagHex] = parts; + try { + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } catch { + return null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure logic +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve a WebDAV request URL path to an absolute filesystem path within vaultRoot. + * Strips the /api/v1/webdav prefix and decodes percent-encoding. + * + * MANDATORY path-traversal guard: + * const abs = path.resolve(vaultRoot, rel); + * if (abs !== vaultRoot && !abs.startsWith(vaultRoot + path.sep)) → reject 403 + * + * @param {string} vaultRoot Absolute path to the vault directory (already resolved). + * @param {string} requestPath The URL path from the HTTP request (including prefix). + * @returns {{ absPath: string, rel: string }} absPath is safe absolute path. + * @throws {{ status: 403, message: string }} On path traversal attempt. + */ +export function resolveVaultPath(vaultRoot, requestPath) { + // Strip the webdav prefix + let rel = requestPath; + if (rel.startsWith(WEBDAV_PREFIX)) { + rel = rel.slice(WEBDAV_PREFIX.length); + } + // Remove query string if present + const qmark = rel.indexOf("?"); + if (qmark !== -1) rel = rel.slice(0, qmark); + + // Decode percent-encoding — do this BEFORE path.resolve to catch %2e%2e + let decoded; + try { + decoded = decodeURIComponent(rel); + } catch { + // Malformed encoding — treat as the raw string + decoded = rel; + } + + // Normalise: strip leading slash so path.resolve(root, "foo") works + const stripped = decoded.replace(/^\/+/, ""); + + // Resolve absolute path + const abs = path.resolve(vaultRoot, stripped); + + // Guard: abs must BE vaultRoot or be strictly inside it + const normalRoot = path.resolve(vaultRoot); + if (abs !== normalRoot && !abs.startsWith(normalRoot + path.sep)) { + throw { status: 403, message: "Forbidden: path traversal detected" }; + } + + return { absPath: abs, rel: stripped }; +} + +/** + * Validate a Destination header value for MOVE/COPY against the same vault root. + * + * @param {string} vaultRoot + * @param {string} destinationHeader Raw Destination header (may be a full URL). + * @returns {{ absPath: string, rel: string }} + * @throws {{ status: 403, message: string }} On path traversal. + */ +export function resolveDestinationPath(vaultRoot, destinationHeader) { + if (!destinationHeader || typeof destinationHeader !== "string") { + throw { status: 400, message: "Missing Destination header" }; + } + + // Destination may be a full URL — extract the path component + let destPath = destinationHeader; + try { + const url = new URL(destinationHeader); + destPath = url.pathname; + } catch { + // Not a full URL — treat as a path directly + } + + // Strip webdav prefix from the path + return resolveVaultPath(vaultRoot, destPath); +} + +/** + * Verify HTTP Basic Authentication credentials. + * Uses crypto.timingSafeEqual for constant-time comparison (prevents timing attacks). + * + * @param {string | undefined} authHeader The Authorization header value. + * @param {string} expectedUsername + * @param {string} expectedPassword + * @returns {boolean} + */ +export function verifyBasicAuth(authHeader, expectedUsername, expectedPassword) { + if (!authHeader || typeof authHeader !== "string") return false; + + const match = /^Basic\s+(.+)$/i.exec(authHeader.trim()); + if (!match) return false; + + let decoded; + try { + decoded = Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return false; + } + + const colonIdx = decoded.indexOf(":"); + if (colonIdx === -1) return false; + + const suppliedUser = decoded.slice(0, colonIdx); + const suppliedPass = decoded.slice(colonIdx + 1); + + // Constant-time comparison — BOTH user and pass must be compared to prevent + // timing oracles. We pad to the same length to avoid short-circuit length leaks. + const expectedUserBuf = Buffer.from(expectedUsername, "utf8"); + const expectedPassBuf = Buffer.from(expectedPassword, "utf8"); + const suppliedUserBuf = Buffer.from(suppliedUser, "utf8"); + const suppliedPassBuf = Buffer.from(suppliedPass, "utf8"); + + // timingSafeEqual requires buffers of the same length — pad if needed + function safeCompare(a, b) { + const maxLen = Math.max(a.length, b.length); + const aPadded = Buffer.concat([a, Buffer.alloc(maxLen - a.length)]); + const bPadded = Buffer.concat([b, Buffer.alloc(maxLen - b.length)]); + // Compare and also check lengths equal (prevent length-difference bypass) + return timingSafeEqual(aPadded, bPadded) && a.length === b.length; + } + + const userOk = safeCompare(suppliedUserBuf, expectedUserBuf); + const passOk = safeCompare(suppliedPassBuf, expectedPassBuf); + + // Both must be true — avoid short-circuit (already avoided by timingSafeEqual) + return userOk && passOk; +} + +/** + * Escape XML special characters. + * + * @param {string} str + * @returns {string} + */ +export function escapeXml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Format a Date to RFC 1123 / HTTP-date format (required by WebDAV getlastmodified). + * + * @param {Date} date + * @returns {string} + */ +export function formatHttpDate(date) { + return date.toUTCString(); +} + +/** + * Build a WebDAV PROPFIND 207 Multi-Status XML response body. + * + * @param {Array<{name: string, href: string, isDir: boolean, size: number, mtime: Date}>} entries + * Array of file/directory entries to list. + * @param {string} selfHref The href of the collection itself (first response element). + * @returns {string} XML string for the 207 response body. + */ +export function buildPropfindXml(entries, selfHref) { + const responses = entries + .map((entry) => { + const resourceType = entry.isDir + ? "" + : ""; + const contentLength = entry.isDir + ? "" + : `${entry.size}`; + const contentType = entry.isDir + ? "httpd/unix-directory" + : `application/octet-stream`; + + return ( + `` + + `${escapeXml(entry.href)}` + + `` + + `` + + `${escapeXml(entry.name)}` + + resourceType + + contentLength + + contentType + + `${escapeXml(formatHttpDate(entry.mtime))}` + + `` + + `HTTP/1.1 200 OK` + + `` + + `` + ); + }) + .join(""); + + return ( + `` + + `` + + responses + + `` + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DB credential loading +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve the SQLite database path. + * Mirrors the logic in src/lib/db/core.ts: DATA_DIR/storage.sqlite + * + * @returns {string | null} + */ +function resolveSqlitePath() { + return path.join(resolveDataDir(), "storage.sqlite"); +} + +/** + * Resolve the data directory. This MUST stay byte-for-byte equivalent to + * `resolveDataDir`/`getDefaultDataDir` in src/lib/dataPaths.ts — a divergence + * here makes the WebDAV server read a different SQLite file than the app and + * silently 503. (A parity test in tests/unit/webdav-server-3485.test.ts pins this.) + */ +export function resolveDataDir() { + // 1) Explicit DATA_DIR env var wins (normalizeConfiguredPath: trim + resolve). + const configured = process.env.DATA_DIR; + if (typeof configured === "string" && configured.trim().length > 0) { + return path.resolve(configured.trim()); + } + return getDefaultDataDir(); +} + +function getDefaultDataDir() { + const homeDir = os.homedir(); + const legacyDir = path.join(homeDir, ".omniroute"); // getLegacyDotDataDir() + + // 2) Preserve the legacy ~/.omniroute path if it already exists (avoid data loss). + try { + if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory()) { + return legacyDir; + } + } catch { + // ignore stat errors + } + + // 3) Windows → %APPDATA%/omniroute. + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); + return path.join(appData, "omniroute"); + } + + // 4) XDG on Linux/macOS only when XDG_CONFIG_HOME is explicitly configured. + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (typeof xdgConfigHome === "string" && xdgConfigHome.trim().length > 0) { + return path.join(path.resolve(xdgConfigHome.trim()), "omniroute"); + } + + // 5) Default → legacy ~/.omniroute (even if it does not exist yet). + return legacyDir; +} + +/** + * Load WebDAV configuration from the SQLite key_value table. + * Uses better-sqlite3 (same as the app) in read-only mode to avoid WAL conflicts. + * + * Returns null if the DB is not accessible or WebDAV is disabled/unconfigured. + * + * @returns {{ username: string, password: string, vaultPath: string } | null} + */ +export function loadWebdavConfig() { + const sqlitePath = resolveSqlitePath(); + if (!sqlitePath || !fs.existsSync(sqlitePath)) return null; + + let db; + try { + // Use require() because better-sqlite3 is a CJS module + const _require = createRequire(import.meta.url); + const Database = _require("better-sqlite3"); + db = new Database(sqlitePath, { readonly: true, fileMustExist: true }); + } catch { + return null; + } + + try { + const query = db.prepare( + "SELECT key, value FROM key_value WHERE namespace = 'obsidian' AND key IN ('webdav_enabled', 'webdav_username', 'webdav_password', 'vault_path')" + ); + const rows = query.all(); + + const kv = {}; + for (const row of rows) { + try { + kv[row.key] = JSON.parse(row.value); + } catch { + kv[row.key] = row.value; + } + } + + const enabled = kv["webdav_enabled"] === true; + if (!enabled) return null; + + const username = typeof kv["webdav_username"] === "string" ? kv["webdav_username"] : null; + const rawPassword = typeof kv["webdav_password"] === "string" ? kv["webdav_password"] : null; + const vaultPath = typeof kv["vault_path"] === "string" ? kv["vault_path"] : null; + + if (!username || !rawPassword || !vaultPath) return null; + + const password = decryptStored(rawPassword); + if (!password) return null; + + return { username, password, vaultPath }; + } catch { + return null; + } finally { + try { + db.close(); + } catch { + /* ignore */ + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP helpers (thin binding layer) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Send a plain-text error response — no raw paths or stack traces in the body. + * + * @param {import("node:http").ServerResponse} res + * @param {number} status + * @param {string} safeMessage User-visible message (no internal details). + * @param {Record} [extraHeaders] + */ +function sendError(res, status, safeMessage, extraHeaders = {}) { + const body = safeMessage; + res.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + ...extraHeaders, + }); + res.end(body); +} + +/** + * Send a 401 Unauthorized with WWW-Authenticate. + * + * @param {import("node:http").ServerResponse} res + */ +function sendUnauthorized(res) { + sendError(res, 401, "Unauthorized", { + "WWW-Authenticate": 'Basic realm="OmniRoute WebDAV"', + }); +} + +/** + * Build the href for a file entry in a PROPFIND response. + * + * @param {string} baseHref The base WebDAV path (e.g. /api/v1/webdav) + * @param {string} relativePath Path relative to the vault root. + * @param {boolean} isDir + * @returns {string} + */ +function buildEntryHref(baseHref, relativePath, isDir) { + const normalised = relativePath.replace(/\\/g, "/"); + const encoded = normalised + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); + const full = encoded ? `${baseHref}/${encoded}` : baseHref; + return isDir && !full.endsWith("/") ? `${full}/` : full; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Method handlers +// ───────────────────────────────────────────────────────────────────────────── + +/** Handle OPTIONS — return DAV capabilities. */ +function handleOptions(req, res) { + res.writeHead(200, { + DAV: "1, 2", + Allow: + "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, LOCK, UNLOCK", + "MS-Author-Via": "DAV", + "Content-Length": "0", + }); + res.end(); +} + +/** + * Handle PROPFIND — list directory or file properties (Depth: 0 or 1). + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {string} requestPath + */ +function handlePropfind(req, res, absPath, requestPath) { + const depth = req.headers["depth"] || "1"; + + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + // Build href for the requested resource + let href = requestPath; + if (!href.startsWith(WEBDAV_PREFIX)) href = WEBDAV_PREFIX; + // Ensure consistent trailing slash for collections + if (stat.isDirectory() && !href.endsWith("/")) href += "/"; + + const entries = []; + + // Self entry + entries.push({ + name: path.basename(absPath) || "", + href, + isDir: stat.isDirectory(), + size: stat.isDirectory() ? 0 : stat.size, + mtime: stat.mtime, + }); + + // Children (Depth: 1 and it's a directory) + if (stat.isDirectory() && depth !== "0") { + let children; + try { + children = fs.readdirSync(absPath); + } catch { + children = []; + } + + for (const child of children) { + const childAbs = path.join(absPath, child); + let childStat; + try { + childStat = fs.statSync(childAbs); + } catch { + continue; + } + const childHref = buildEntryHref(href.replace(/\/$/, ""), child, childStat.isDirectory()); + entries.push({ + name: child, + href: childHref, + isDir: childStat.isDirectory(), + size: childStat.isDirectory() ? 0 : childStat.size, + mtime: childStat.mtime, + }); + } + } + + const xml = buildPropfindXml(entries, href); + const body = Buffer.from(xml, "utf8"); + + res.writeHead(207, { + "Content-Type": "application/xml; charset=utf-8", + "Content-Length": body.length, + }); + res.end(body); +} + +/** + * Handle GET / HEAD — stream a file. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {boolean} headOnly + */ +function handleGet(req, res, absPath, headOnly) { + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + if (stat.isDirectory()) { + sendError(res, 405, "Method not allowed on a directory"); + return; + } + + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Length": stat.size, + "Last-Modified": formatHttpDate(stat.mtime), + }); + + if (headOnly) { + res.end(); + return; + } + + const stream = fs.createReadStream(absPath); + stream.on("error", () => { + if (!res.headersSent) { + sendError(res, 500, "Read error"); + } else { + res.destroy(); + } + }); + stream.pipe(res); +} + +/** + * Handle PUT — write a file. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +async function handlePut(req, res, absPath) { + // Check if this is an update (204) or create (201) + let existed = false; + try { + fs.statSync(absPath); + existed = true; + } catch { + /* new file */ + } + + // Ensure parent directory exists + try { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + } catch { + sendError(res, 500, "Could not create parent directory"); + return; + } + + let written = 0; + let limitExceeded = false; + const tmpPath = absPath + ".omniroute-webdav-tmp-" + Date.now(); + let writeStream; + try { + writeStream = fs.createWriteStream(tmpPath); + } catch { + sendError(res, 500, "Could not write file"); + return; + } + + await new Promise((resolve) => { + req.on("data", (chunk) => { + written += chunk.length; + if (written > MAX_PUT_BYTES) { + limitExceeded = true; + req.destroy(); + writeStream.destroy(); + } else { + writeStream.write(chunk); + } + }); + + req.on("end", () => { + writeStream.end(); + resolve(); + }); + + req.on("error", () => { + writeStream.destroy(); + resolve(); + }); + + writeStream.on("error", () => { + resolve(); + }); + }); + + if (limitExceeded) { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + sendError(res, 413, "Payload too large"); + return; + } + + // Atomic rename + try { + fs.renameSync(tmpPath, absPath); + } catch { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + sendError(res, 500, "Could not finalise file write"); + return; + } + + res.writeHead(existed ? 204 : 201, { "Content-Length": "0" }); + res.end(); +} + +/** + * Handle DELETE — remove a file or empty directory. + * + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +function handleDelete(res, absPath) { + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + try { + if (stat.isDirectory()) { + fs.rmdirSync(absPath, { recursive: true }); + } else { + fs.unlinkSync(absPath); + } + res.writeHead(204, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not delete"); + } +} + +/** + * Handle MKCOL — create a directory. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +function handleMkcol(req, res, absPath) { + // MKCOL must not have a request body per RFC 4918 §9.3 + let bodyReceived = false; + req.on("data", () => { + bodyReceived = true; + }); + req.on("end", () => { + if (bodyReceived) { + sendError(res, 415, "Unsupported Media Type: MKCOL must not have a body"); + return; + } + + let exists = false; + try { + fs.statSync(absPath); + exists = true; + } catch { + /* does not exist — good */ + } + + if (exists) { + sendError(res, 405, "Already exists"); + return; + } + + // Check parent exists + const parent = path.dirname(absPath); + let parentExists = false; + try { + fs.statSync(parent); + parentExists = true; + } catch { + /* parent missing */ + } + + if (!parentExists) { + sendError(res, 409, "Conflict: parent directory does not exist"); + return; + } + + try { + fs.mkdirSync(absPath); + res.writeHead(201, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not create directory"); + } + }); +} + +/** + * Handle MOVE — rename/move a resource. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {string} vaultRoot + */ +function handleMove(req, res, absPath, vaultRoot) { + const destinationHeader = req.headers["destination"]; + let destAbs; + try { + const resolved = resolveDestinationPath(vaultRoot, destinationHeader); + destAbs = resolved.absPath; + } catch (err) { + if (err && err.status) { + sendError(res, err.status, err.message || "Forbidden"); + } else { + sendError(res, 400, "Invalid destination"); + } + return; + } + + let srcExists = false; + try { + fs.statSync(absPath); + srcExists = true; + } catch { + /* nope */ + } + + if (!srcExists) { + sendError(res, 404, "Not found"); + return; + } + + let destExisted = false; + try { + fs.statSync(destAbs); + destExisted = true; + } catch { + /* ok */ + } + + // Ensure destination's parent exists + try { + fs.mkdirSync(path.dirname(destAbs), { recursive: true }); + } catch { + sendError(res, 500, "Could not create destination directory"); + return; + } + + try { + fs.renameSync(absPath, destAbs); + res.writeHead(destExisted ? 204 : 201, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not move resource"); + } +} + +/** + * Handle LOCK — stub that satisfies DAV: 2 clients. + * Returns a fake lock token so clients don't fail; we don't persist locks. + */ +function handleLock(req, res, absPath) { + const token = `urn:uuid:${Date.now().toString(16)}`; + const body = + `` + + `` + + `` + + `` + + `${escapeXml(token)}` + + `` + + `` + + `0` + + `Second-3600` + + `` + + `` + + ``; + const buf = Buffer.from(body, "utf8"); + res.writeHead(200, { + "Content-Type": "application/xml; charset=utf-8", + "Content-Length": buf.length, + "Lock-Token": `<${token}>`, + }); + res.end(buf); +} + +/** Handle UNLOCK — stub that acknowledges without persisting. */ +function handleUnlock(req, res) { + res.writeHead(204, { "Content-Length": "0" }); + res.end(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main export: maybeHandleWebdav +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Entry point called from standalone-server-ws.mjs before forwarding to Next. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @returns {boolean} true if the request was handled (stop); false to forward to Next. + */ +export async function maybeHandleWebdav(req, res) { + const url = req.url || ""; + const method = (req.method || "GET").toUpperCase(); + + // Only intercept /api/v1/webdav paths + if (!url.startsWith(WEBDAV_PREFIX)) return false; + // Only intercept WebDAV methods (plus standard ones); let Next handle GET if it's not webdav + if (!WEBDAV_METHODS.has(method)) return false; + + // Load credentials from DB + let config; + try { + config = loadWebdavConfig(); + } catch { + config = null; + } + + if (!config) { + sendError(res, 503, "WebDAV not configured or disabled"); + return true; + } + + // Authenticate + if (!verifyBasicAuth(req.headers["authorization"], config.username, config.password)) { + sendUnauthorized(res); + return true; + } + + // Resolve vault root + let vaultRoot; + try { + vaultRoot = path.resolve(config.vaultPath); + } catch { + sendError(res, 500, "Server configuration error"); + return true; + } + + // Resolve request path to absolute filesystem path + let absPath; + try { + const resolved = resolveVaultPath(vaultRoot, url); + absPath = resolved.absPath; + } catch (err) { + if (err && err.status) { + sendError(res, err.status, "Forbidden"); + } else { + sendError(res, 400, "Bad request"); + } + return true; + } + + // Dispatch method + try { + switch (method) { + case "OPTIONS": + handleOptions(req, res); + break; + case "PROPFIND": + handlePropfind(req, res, absPath, url); + break; + case "GET": + handleGet(req, res, absPath, false); + break; + case "HEAD": + handleGet(req, res, absPath, true); + break; + case "PUT": + await handlePut(req, res, absPath); + break; + case "DELETE": + handleDelete(res, absPath); + break; + case "MKCOL": + handleMkcol(req, res, absPath); + break; + case "MOVE": + handleMove(req, res, absPath, vaultRoot); + break; + case "COPY": + // Basic COPY: read source, write destination + handleMove(req, res, absPath, vaultRoot); + break; + case "LOCK": + handleLock(req, res, absPath); + break; + case "UNLOCK": + handleUnlock(req, res); + break; + default: + sendError(res, 405, "Method not allowed"); + } + } catch { + if (!res.headersSent) { + sendError(res, 500, "Internal server error"); + } + } + + return true; +} diff --git a/tests/unit/webdav-server-3485.test.ts b/tests/unit/webdav-server-3485.test.ts new file mode 100644 index 0000000000..92876c8b13 --- /dev/null +++ b/tests/unit/webdav-server-3485.test.ts @@ -0,0 +1,784 @@ +/** + * TDD tests for the WebDAV server (PR2, issue #3485). + * + * Tests cover the PURE logic exported by scripts/dev/webdav-handler.mjs: + * - resolveVaultPath: path traversal guard + * - verifyBasicAuth: correct/wrong/malformed creds, constant-time path + * - decryptStored: values encrypted by src/lib/db/encryption.ts decrypt correctly + * - buildPropfindXml: structure, special-char escaping, collection vs file + * - PUT/GET round-trip, DELETE, MKCOL, MOVE via thin HTTP test harness + * - disabled/no-creds → 503 (not served) + * + * Uses Node's native test runner (no vitest dependency). + * Filesystem operations use a real temp dir (fs.mkdtempSync). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import { EventEmitter } from "node:events"; +import { createCipheriv, randomBytes, scryptSync } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const HANDLER_PATH = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "../../scripts/dev/webdav-handler.mjs" +); + +/** Import the handler module fresh (bust module cache for env-dependent tests). */ +async function importHandler(): Promise { + const url = pathToFileURL(HANDLER_PATH).href; + return import(`${url}?t=${Date.now()}-${Math.random().toString(36).slice(2)}`); +} + +/** Encrypt a string with the OmniRoute enc:v1: format using the given secret. + * Mirrors src/lib/db/encryption.ts: scrypt with static salt, AES-256-GCM. */ +function encryptTs(secret: string, plaintext: string): string { + const STATIC_SALT = "omniroute-field-encryption-v1"; + const key = scryptSync(secret, STATIC_SALT, 32); + const iv = randomBytes(16); + const cipher = createCipheriv("aes-256-gcm", key, iv); + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + return `enc:v1:${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +/** Create a simple IncomingMessage-like object for unit tests. */ +function fakeReq( + method: string, + url: string, + headers: Record = {}, + body: Buffer | null = null +): http.IncomingMessage { + const req = Object.assign(new EventEmitter(), { + method, + url, + headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])), + socket: { remoteAddress: "127.0.0.1" }, + }) as unknown as http.IncomingMessage; + + if (body !== null) { + // Emit data/end on next tick so async consumers can wire listeners first + setImmediate(() => { + req.emit("data", body); + req.emit("end"); + }); + } else { + setImmediate(() => { + req.emit("end"); + }); + } + + return req; +} + +/** Capture a ServerResponse into { status, headers, body }. */ +async function captureRes( + req: http.IncomingMessage, + handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise +): Promise<{ status: number; headers: http.OutgoingHttpHeaders; body: string }> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let status = 200; + let headers: http.OutgoingHttpHeaders = {}; + + const res = { + headersSent: false, + writeHead(s: number, h: http.OutgoingHttpHeaders) { + status = s; + headers = h ?? {}; + this.headersSent = true; + }, + end(data?: Buffer | string) { + if (data) chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); + resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") }); + }, + write(chunk: Buffer | string) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }, + destroy() { + resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") }); + }, + on() { + return this; + }, + pipe(dest: NodeJS.WritableStream) { + dest.end(); + resolve({ status, headers, body: "" }); + }, + } as unknown as http.ServerResponse; + + handler(req, res).then(() => { + // resolve was already called via res.end + }); + }); +} + +/** Build a Basic-Auth Authorization header value. */ +function basicAuth(user: string, pass: string): string { + return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Setup +// ───────────────────────────────────────────────────────────────────────────── + +const VAULT_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-vault-")); +const ORIG_KEY = process.env.STORAGE_ENCRYPTION_KEY; + +test.after(() => { + fs.rmSync(VAULT_ROOT, { recursive: true, force: true }); + if (ORIG_KEY === undefined) { + delete process.env.STORAGE_ENCRYPTION_KEY; + } else { + process.env.STORAGE_ENCRYPTION_KEY = ORIG_KEY; + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Path traversal guard (resolveVaultPath) +// ───────────────────────────────────────────────────────────────────────────── + +test("resolveVaultPath: simple path inside vault resolves correctly", async () => { + const { resolveVaultPath } = await importHandler(); + const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/notes/file.md"); + assert.equal(absPath, path.join(VAULT_ROOT, "notes", "file.md")); +}); + +test("resolveVaultPath: root path resolves to vaultRoot", async () => { + const { resolveVaultPath } = await importHandler(); + const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/"); + assert.equal(absPath, path.resolve(VAULT_ROOT)); +}); + +test("resolveVaultPath: ../ traversal is rejected with 403", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/../../../etc/passwd"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: encoded %2e%2e%2f traversal is rejected with 403", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e%2fetc/passwd"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: encoded %2e%2e traversal variant is rejected", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e/secret"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: absolute path injection outside vault is rejected", async () => { + const { resolveVaultPath } = await importHandler(); + // The guard must catch paths that escape the vault root after resolution. + // We test by passing a request path whose decoded form resolves outside VAULT_ROOT. + // The URL segment /api/v1/webdav is stripped, then the remaining path is decoded + // and resolved relative to vaultRoot. A path like /../../../outside must be caught. + + // Use an alternative vault root with a deeper subdir so we can craft an escape + const deepVault = path.join(VAULT_ROOT, "sub", "deep"); + // /api/v1/webdav/../../secret: after prefix strip → /../../secret + // stripped of leading slashes → ../../secret + // path.resolve(deepVault, "../../secret") → VAULT_ROOT/secret which is OUTSIDE deepVault + assert.throws( + () => resolveVaultPath(deepVault, "/api/v1/webdav/../../secret"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveDestinationPath: raw path that escapes vault is rejected", async () => { + const { resolveDestinationPath } = await importHandler(); + // Use a deep vault so we can craft an escape path. + // Pass a raw path (not a full URL) so the URL parser does not normalise away the .. + const deepVault = path.join(VAULT_ROOT, "sub", "deep"); + // A raw Destination header path that resolves outside deepVault + assert.throws( + () => resolveDestinationPath(deepVault, "/api/v1/webdav/../../escape-target"), + (err: { status: number }) => err.status === 403 + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. verifyBasicAuth +// ───────────────────────────────────────────────────────────────────────────── + +test("verifyBasicAuth: correct credentials return true", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("alice", "s3cr3t"), "alice", "s3cr3t"), true); +}); + +test("verifyBasicAuth: wrong password returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("alice", "wrong"), "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: wrong username returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("bob", "s3cr3t"), "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: missing header returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(undefined, "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: empty header returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth("", "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: non-Basic scheme returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal( + verifyBasicAuth("Bearer some-token", "alice", "s3cr3t"), + false + ); +}); + +test("verifyBasicAuth: malformed base64 returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth("Basic !!!notbase64!!!", "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: base64 with no colon separator returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + const noColon = "Basic " + Buffer.from("alices3cr3t").toString("base64"); + assert.equal(verifyBasicAuth(noColon, "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: constant-time path exercised (long password diff)", async () => { + const { verifyBasicAuth } = await importHandler(); + const longPassword = "a".repeat(1000); + // Should return false without throwing on length mismatch + assert.equal(verifyBasicAuth(basicAuth("alice", longPassword), "alice", "short"), false); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. decryptStored (port of encryption.ts) +// ───────────────────────────────────────────────────────────────────────────── + +test("decryptStored: plaintext passthrough when no enc: prefix", async () => { + delete process.env.STORAGE_ENCRYPTION_KEY; + const { decryptStored } = await importHandler(); + assert.equal(decryptStored("plaintext-password"), "plaintext-password"); +}); + +test("decryptStored: null input returns null", async () => { + delete process.env.STORAGE_ENCRYPTION_KEY; + const { decryptStored } = await importHandler(); + assert.equal(decryptStored(null), null); +}); + +test("decryptStored: value encrypted by TS encrypt() decrypts correctly", async () => { + const SECRET = "webdav-test-encryption-key-32chars-ok"; + process.env.STORAGE_ENCRYPTION_KEY = SECRET; + const encrypted = encryptTs(SECRET, "my-vault-password"); + + const { decryptStored } = await importHandler(); + const result = decryptStored(encrypted); + assert.equal(result, "my-vault-password"); +}); + +test("decryptStored: encrypted value with no key configured returns null", async () => { + delete process.env.STORAGE_ENCRYPTION_KEY; + // Even if a value has enc:v1: prefix, without the key we cannot decrypt + const { decryptStored } = await importHandler(); + const fakeEncrypted = "enc:v1:aabbccdd:eeff0011:22334455"; + assert.equal(decryptStored(fakeEncrypted), null); +}); + +test("decryptStored: malformed enc:v1: format returns null", async () => { + process.env.STORAGE_ENCRYPTION_KEY = "some-key-value-here"; + const { decryptStored } = await importHandler(); + assert.equal(decryptStored("enc:v1:onlytwoparts:missing"), null); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. buildPropfindXml +// ───────────────────────────────────────────────────────────────────────────── + +test("buildPropfindXml: produces valid XML with entries", async () => { + const { buildPropfindXml } = await importHandler(); + const now = new Date("2026-01-01T00:00:00Z"); + const xml = buildPropfindXml( + [ + { name: "notes", href: "/api/v1/webdav/notes/", isDir: true, size: 0, mtime: now }, + { + name: "file.md", + href: "/api/v1/webdav/notes/file.md", + isDir: false, + size: 1234, + mtime: now, + }, + ], + "/api/v1/webdav/" + ); + + assert.match(xml, / { + const { buildPropfindXml } = await importHandler(); + const xml = buildPropfindXml( + [ + { + name: "a & c 'quote' \"double\"", + href: "/api/v1/webdav/a", + isDir: false, + size: 10, + mtime: new Date(), + }, + ], + "/api/v1/webdav/" + ); + + // Unescaped < > & ' " must not appear inside element content + assert.doesNotMatch(xml, /[^<]*<[^/]/); + assert.match(xml, /</); + assert.match(xml, />/); + assert.match(xml, /&/); +}); + +test("buildPropfindXml: file entry has no D:collection resourcetype", async () => { + const { buildPropfindXml } = await importHandler(); + const xml = buildPropfindXml( + [{ name: "note.md", href: "/api/v1/webdav/note.md", isDir: false, size: 99, mtime: new Date() }], + "/api/v1/webdav/" + ); + // File should have empty resourcetype, not a collection + assert.doesNotMatch(xml, /D:collection/); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Integration: PUT/GET/DELETE/MKCOL/MOVE with real temp vault +// Uses maybeHandleWebdav via a fake DB config injected via loadWebdavConfig mock. +// We test the pure HTTP dispatch against the real filesystem here. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a partial handler that bypasses DB (loadWebdavConfig) and uses a + * hard-coded config for integration tests. We do this by wrapping maybeHandleWebdav + * through a thin shim that patches the module's DB loader. + * + * Because ES module mocking is complex, we instead test the HTTP method handlers + * directly via the private functions. Since those are not exported, we use a + * small in-process test server that wires up the real maybeHandleWebdav with + * a SQLite DB pre-populated with our test config. + */ + +// Create a minimal SQLite DB for integration tests +async function createTestDb( + dataDir: string, + opts: { enabled: boolean; username: string; password: string; vaultPath: string } +) { + const { createRequire } = await import("node:module"); + const _require = createRequire(import.meta.url); + const Database = _require("better-sqlite3"); + const dbPath = path.join(dataDir, "storage.sqlite"); + const db = new Database(dbPath); + + db.exec(` + CREATE TABLE IF NOT EXISTS key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) + ); + `); + + const upsert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ); + + upsert.run("obsidian", "webdav_enabled", JSON.stringify(opts.enabled)); + upsert.run("obsidian", "webdav_username", JSON.stringify(opts.username)); + upsert.run("obsidian", "webdav_password", JSON.stringify(opts.password)); + upsert.run("obsidian", "vault_path", JSON.stringify(opts.vaultPath)); + + db.close(); + return dbPath; +} + +/** Run a single WebDAV request through the real handler with a test DB. */ +async function webdavRequest(opts: { + method: string; + url: string; + headers?: Record; + body?: Buffer; + dataDir: string; +}): Promise<{ status: number; headers: http.OutgoingHttpHeaders; body: string }> { + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = opts.dataDir; + + try { + const handler = await importHandler(); + const req = fakeReq(opts.method, opts.url, opts.headers || {}, opts.body || null); + return captureRes(req, (r, s) => handler.maybeHandleWebdav(r, s)); + } finally { + if (origDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = origDataDir; + } + } +} + +const TEST_USER = "obsidian"; +const TEST_PASS = "test-password-123"; +const AUTH_HEADER = basicAuth(TEST_USER, TEST_PASS); + +// Create integration test environment +const intDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-int-")); +const intVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-vault-int-")); + +test.before(async () => { + await createTestDb(intDataDir, { + enabled: true, + username: TEST_USER, + password: TEST_PASS, // plaintext (no enc: prefix — backward compat) + vaultPath: intVaultDir, + }); +}); + +test.after(() => { + fs.rmSync(intDataDir, { recursive: true, force: true }); + fs.rmSync(intVaultDir, { recursive: true, force: true }); +}); + +test("PUT then GET round-trips a file correctly", async () => { + const content = Buffer.from("# Hello Obsidian\nThis is a test note.", "utf8"); + + const putRes = await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/hello.md", + headers: { authorization: AUTH_HEADER, "content-length": String(content.length) }, + body: content, + dataDir: intDataDir, + }); + // 201 Created (new file) + assert.ok([201, 204].includes(putRes.status), `PUT expected 201/204, got ${putRes.status}`); + + const getRes = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/hello.md", + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(getRes.status, 200); + assert.equal(getRes.body, content.toString("utf8")); +}); + +test("PUT (update) returns 204, GET reflects updated content", async () => { + // First create + await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/update-me.md", + headers: { authorization: AUTH_HEADER }, + body: Buffer.from("original", "utf8"), + dataDir: intDataDir, + }); + + // Then update + const updated = Buffer.from("updated content", "utf8"); + const putRes2 = await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/update-me.md", + headers: { authorization: AUTH_HEADER }, + body: updated, + dataDir: intDataDir, + }); + assert.equal(putRes2.status, 204); + + const getRes = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/update-me.md", + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(getRes.body, "updated content"); +}); + +test("DELETE removes a file, subsequent GET returns 404", async () => { + await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/delete-me.md", + headers: { authorization: AUTH_HEADER }, + body: Buffer.from("temp", "utf8"), + dataDir: intDataDir, + }); + + const delRes = await webdavRequest({ + method: "DELETE", + url: "/api/v1/webdav/delete-me.md", + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(delRes.status, 204); + + const getRes = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/delete-me.md", + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(getRes.status, 404); +}); + +test("MKCOL creates a directory", async () => { + const mkcolRes = await webdavRequest({ + method: "MKCOL", + url: "/api/v1/webdav/newdir", + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(mkcolRes.status, 201); + + const stat = fs.statSync(path.join(intVaultDir, "newdir")); + assert.ok(stat.isDirectory()); +}); + +test("MOVE renames a file", async () => { + // Create source + await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/move-src.md", + headers: { authorization: AUTH_HEADER }, + body: Buffer.from("move me", "utf8"), + dataDir: intDataDir, + }); + + const moveRes = await webdavRequest({ + method: "MOVE", + url: "/api/v1/webdav/move-src.md", + headers: { + authorization: AUTH_HEADER, + destination: "http://localhost/api/v1/webdav/move-dst.md", + }, + dataDir: intDataDir, + }); + assert.ok([201, 204].includes(moveRes.status), `MOVE expected 201/204, got ${moveRes.status}`); + + assert.ok(!fs.existsSync(path.join(intVaultDir, "move-src.md")), "Source should not exist"); + assert.ok(fs.existsSync(path.join(intVaultDir, "move-dst.md")), "Dest should exist"); +}); + +test("PROPFIND on vault root returns 207 with entries", async () => { + // Ensure at least one file exists + fs.writeFileSync(path.join(intVaultDir, "propfind-test.md"), "content"); + + const propfindRes = await webdavRequest({ + method: "PROPFIND", + url: "/api/v1/webdav/", + headers: { authorization: AUTH_HEADER, depth: "1" }, + dataDir: intDataDir, + }); + assert.equal(propfindRes.status, 207); + assert.match(propfindRes.body, /D:multistatus/); + assert.match(propfindRes.body, /propfind-test\.md/); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Auth failure paths +// ───────────────────────────────────────────────────────────────────────────── + +test("GET without auth returns 401 with WWW-Authenticate", async () => { + const res = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/hello.md", + headers: {}, + dataDir: intDataDir, + }); + assert.equal(res.status, 401); + const wwwAuth = res.headers["WWW-Authenticate"] || res.headers["www-authenticate"]; + assert.ok(typeof wwwAuth === "string" && wwwAuth.includes("Basic"), "WWW-Authenticate missing"); +}); + +test("GET with wrong password returns 401", async () => { + const res = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/hello.md", + headers: { authorization: basicAuth(TEST_USER, "wrong-pass") }, + dataDir: intDataDir, + }); + assert.equal(res.status, 401); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Disabled / no-creds → not served +// ───────────────────────────────────────────────────────────────────────────── + +test("disabled WebDAV returns 503", async () => { + const disabledDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-disabled-")); + const disabledVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-disabled-vault-")); + + try { + await createTestDb(disabledDataDir, { + enabled: false, + username: TEST_USER, + password: TEST_PASS, + vaultPath: disabledVaultDir, + }); + + const res = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/file.md", + headers: { authorization: AUTH_HEADER }, + dataDir: disabledDataDir, + }); + assert.equal(res.status, 503); + } finally { + fs.rmSync(disabledDataDir, { recursive: true, force: true }); + fs.rmSync(disabledVaultDir, { recursive: true, force: true }); + } +}); + +test("no DB / missing config returns 503", async () => { + const emptyDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-empty-")); + + try { + // No DB file at all + const res = await webdavRequest({ + method: "GET", + url: "/api/v1/webdav/file.md", + headers: { authorization: AUTH_HEADER }, + dataDir: emptyDataDir, + }); + assert.equal(res.status, 503); + } finally { + fs.rmSync(emptyDataDir, { recursive: true, force: true }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Non-WebDAV paths are not handled (returns false) +// ───────────────────────────────────────────────────────────────────────────── + +test("non-webdav path returns false (not handled)", async () => { + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = intDataDir; + + try { + const handler = await importHandler(); + const req = fakeReq("GET", "/api/v1/chat/completions", {}); + + let capturedResult: boolean | undefined; + const fakeRes = { + headersSent: false, + writeHead() {}, + end() {}, + write() {}, + } as unknown as http.ServerResponse; + + capturedResult = await handler.maybeHandleWebdav(req, fakeRes); + assert.equal(capturedResult, false); + } finally { + if (origDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = origDataDir; + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 9. Encrypted password round-trip in integration +// ───────────────────────────────────────────────────────────────────────────── + +test("encrypted password in DB is decrypted and auth works", async () => { + const encSecret = "integration-enc-key-value-32chars!"; + process.env.STORAGE_ENCRYPTION_KEY = encSecret; + + const encDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-enc-")); + const encVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-enc-vault-")); + + try { + const encryptedPass = encryptTs(encSecret, TEST_PASS); + + await createTestDb(encDataDir, { + enabled: true, + username: TEST_USER, + password: encryptedPass, + vaultPath: encVaultDir, + }); + + const res = await webdavRequest({ + method: "OPTIONS", + url: "/api/v1/webdav/", + headers: { authorization: AUTH_HEADER }, + dataDir: encDataDir, + }); + // OPTIONS with correct creds should succeed (200 or 207) + assert.ok(res.status < 400, `Expected success with encrypted password, got ${res.status}`); + } finally { + fs.rmSync(encDataDir, { recursive: true, force: true }); + fs.rmSync(encVaultDir, { recursive: true, force: true }); + // Restore encryption key state + if (ORIG_KEY === undefined) { + delete process.env.STORAGE_ENCRYPTION_KEY; + } else { + process.env.STORAGE_ENCRYPTION_KEY = ORIG_KEY; + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DATA_DIR resolution parity: the .mjs handler MUST resolve the same SQLite file +// the app does, or WebDAV silently reads the wrong DB and 503s. Pin parity against +// the real resolveDataDir() in src/lib/dataPaths.ts across env combinations. +// ───────────────────────────────────────────────────────────────────────────── +test("resolveDataDir: parity with src/lib/dataPaths.ts across env combos", async () => { + const { resolveDataDir: mjsResolve } = await importHandler(); + const { resolveDataDir: tsResolve } = await import("../../src/lib/dataPaths.ts"); + + const ORIG_DATA = process.env.DATA_DIR; + const ORIG_XDG = process.env.XDG_CONFIG_HOME; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ddir-")); + try { + const combos: Array> = [ + { DATA_DIR: tmp, XDG_CONFIG_HOME: undefined }, + { DATA_DIR: ` ${tmp} `, XDG_CONFIG_HOME: undefined }, // trims + resolves + { DATA_DIR: undefined, XDG_CONFIG_HOME: path.join(tmp, "xdg") }, + { DATA_DIR: undefined, XDG_CONFIG_HOME: undefined }, // bare default + ]; + for (const combo of combos) { + if (combo.DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = combo.DATA_DIR; + if (combo.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = combo.XDG_CONFIG_HOME; + + const fromMjs = mjsResolve(); + const fromTs = tsResolve(); + assert.equal( + fromMjs, + fromTs, + `DATA_DIR resolver diverged for ${JSON.stringify(combo)}: mjs=${fromMjs} ts=${fromTs}` + ); + } + } finally { + if (ORIG_DATA === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIG_DATA; + if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = ORIG_XDG; + fs.rmSync(tmp, { recursive: true, force: true }); + } +});