From dbbcc65693843fcefd1ee626646acda45a5bff33 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:33:38 -0300 Subject: [PATCH] feat(obsidian): add WebDAV config route + encrypt creds at rest (#3485 part 1) (#3677) Part 1 of #3485. Adds /api/settings/obsidian/webdav (GET/POST/DELETE) wiring the ready obsidianSync lib, encrypts webdav password + obsidian token at rest, removes the duplicate UI block, drops the KNOWN_MISSING entry. WebDAV file server is part 2. --- CHANGELOG.md | 2 + scripts/check/check-fetch-targets.mjs | 2 +- .../components/ObsidianSourceCard.tsx | 105 ------ src/app/api/settings/obsidian/webdav/route.ts | 81 +++++ src/lib/db/obsidian.ts | 17 +- tests/unit/obsidian-webdav-route.test.ts | 301 ++++++++++++++++++ 6 files changed, 398 insertions(+), 110 deletions(-) create mode 100644 src/app/api/settings/obsidian/webdav/route.ts create mode 100644 tests/unit/obsidian-webdav-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb56603dc..2590c26161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ ### ๐Ÿ”ง Bug Fixes +- **Obsidian/WebDAV**: add the `/api/settings/obsidian/webdav` config route (enable/disable vault sync), encrypt WebDAV credentials at rest, and remove the duplicate UI block (#3485, part 1). + - **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when โ‰ฅ1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests. - **Resilience**: clear stale transient connection cooldowns on startup so a prior unclean crash no longer makes every request time out at 120s after restart (#3625) diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 6c504376ac..b51517e208 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -24,7 +24,7 @@ const IGNORE = [ // inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a // chamada morta. NรƒO adicione novos aqui sem justificativa โ€” esse รฉ o ponto do gate. const KNOWN_MISSING = new Set([ - "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx โ€” sรณ existe /api/settings/obsidian + // All previously known-missing routes have been resolved. ]); function walk(dir, acc = []) { diff --git a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx index 35c29e502c..18370cec98 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx @@ -393,111 +393,6 @@ export default function ObsidianSourceCard() { )} -
-
- Vault Sync (WebDAV) -
-

- Sync your vault to Obsidian mobile using WebDAV over Tailscale. - Install the "WebDAV Sync" plugin on Obsidian mobile. -

- - {!webdavEnabled ? ( -
- -
- setVaultPath(e.target.value)} - placeholder="/Users/you/Documents/Obsidian" - disabled={webdavBusy} - className="font-mono text-sm flex-1" - /> - -
-
- ) : ( -
-
- cloud_sync -
-

WebDAV sync enabled

-

{getWebdavUrl()}

-
- -
- -
-

Configure Obsidian Mobile

-

- Install the "WebDAV Sync" plugin, then enter the following: -

- -
- -
- - {getWebdavUrl()} - -
-
- -
- -
- - {webdavUsername ?? "โ€”"} - -
-
- -
- -
-
- - {showPassword ? (webdavPassword ?? "โ€”") : "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข"} - -
- -
-
- -

- Use your Tailscale IP instead of localhost when configuring on mobile. - {" "}Both devices must be on the same Tailscale network. -

-
-
- )} -
)} diff --git a/src/app/api/settings/obsidian/webdav/route.ts b/src/app/api/settings/obsidian/webdav/route.ts new file mode 100644 index 0000000000..1465b1dafd --- /dev/null +++ b/src/app/api/settings/obsidian/webdav/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { + getObsidianSyncStatus, + enableObsidianVaultSync, + disableObsidianVaultSync, +} from "@/lib/obsidianSync"; + +const enableSchema = z + .object({ + vaultPath: z.string().min(1).max(4096), + }) + .strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 }); + } + + try { + const status = await getObsidianSyncStatus(); + return NextResponse.json({ + webdavEnabled: status.webdavEnabled, + webdavUsername: status.webdavEnabled ? status.webdavUsername : null, + webdavPassword: status.webdavEnabled ? status.webdavPassword : null, + vaultPath: status.vaultPath, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 }); + } + + const parsed = enableSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + buildErrorBody(400, "Missing or invalid vaultPath"), + { status: 400 } + ); + } + + const result = await enableObsidianVaultSync(parsed.data.vaultPath); + if (!result.success) { + return NextResponse.json(buildErrorBody(400, result.error), { status: 400 }); + } + + return NextResponse.json({ + username: result.username, + password: result.password, + vaultPath: result.vaultPath, + }); +} + +export async function DELETE(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 }); + } + + const result = await disableObsidianVaultSync(); + if (!result.success) { + return NextResponse.json(buildErrorBody(500, result.error ?? "Failed to disable WebDAV sync"), { + status: 500, + }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/lib/db/obsidian.ts b/src/lib/db/obsidian.ts index da1291b230..be74db78ad 100644 --- a/src/lib/db/obsidian.ts +++ b/src/lib/db/obsidian.ts @@ -1,5 +1,6 @@ import { getDbInstance } from "./core"; import { getApiKeyContextSource } from "./apiKeyContextSources"; +import { encrypt, decrypt } from "./encryption"; const OBSIDIAN_NAMESPACE = "obsidian"; const OBSIDIAN_TOKEN_KEY = "api_key"; @@ -14,7 +15,11 @@ export function getObsidianToken(): string | null { const row = db .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") .get(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY) as KeyValueRow | undefined; - return typeof row?.value === "string" ? JSON.parse(row.value) : null; + if (typeof row?.value !== "string") return null; + const parsed = JSON.parse(row.value); + if (typeof parsed !== "string" || parsed.length === 0) return null; + // Graceful fallback: if decrypt fails (e.g. no key set) return as-is + return decrypt(parsed) ?? parsed; } catch { return null; } @@ -23,9 +28,10 @@ export function getObsidianToken(): string | null { export function setObsidianToken(token: string): void { try { const db = getDbInstance(); + const encrypted = encrypt(token) ?? token; db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" - ).run(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY, JSON.stringify(token)); + ).run(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY, JSON.stringify(encrypted)); } catch { // Non-fatal โ€” token still works in-memory if persistence fails. } @@ -168,7 +174,9 @@ export function getWebdavPassword(): string | null { .get(OBSIDIAN_NAMESPACE, "webdav_password") as KeyValueRow | undefined; if (typeof row?.value === "string") { const parsed = JSON.parse(row.value); - return typeof parsed === "string" && parsed.length > 0 ? parsed : null; + if (typeof parsed !== "string" || parsed.length === 0) return null; + // Graceful fallback: if decrypt fails return as-is (plaintext backward compat) + return decrypt(parsed) ?? parsed; } return null; } catch { @@ -179,9 +187,10 @@ export function getWebdavPassword(): string | null { export function setWebdavPassword(password: string): void { try { const db = getDbInstance(); + const encrypted = encrypt(password) ?? password; db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" - ).run(OBSIDIAN_NAMESPACE, "webdav_password", JSON.stringify(password)); + ).run(OBSIDIAN_NAMESPACE, "webdav_password", JSON.stringify(encrypted)); } catch { // Non-fatal. } diff --git a/tests/unit/obsidian-webdav-route.test.ts b/tests/unit/obsidian-webdav-route.test.ts new file mode 100644 index 0000000000..cfc839a607 --- /dev/null +++ b/tests/unit/obsidian-webdav-route.test.ts @@ -0,0 +1,301 @@ +/** + * TDD tests for /api/settings/obsidian/webdav route (PR1 of #3485). + * + * Covers: + * - GET: no config โ†’ disabled shape with null creds + * - POST: valid temp dir โ†’ enabled, returns { username, password } + * - POST: non-existent path โ†’ 400 with no stack trace leaked + * - DELETE: after enable โ†’ disabled, creds cleared + * - Unauthenticated โ†’ 401 + * - Encryption round-trip: set password โ†’ raw DB value is NOT plaintext โ†’ get returns plaintext + */ + +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 type { NextRequest } from "next/server"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-obsidian-webdav-route-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; +const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY; + +// Set DATA_DIR before any module imports so the DB picks up the temp dir. +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +// Import settings to control auth requirements +const settingsDb = await import("../../src/lib/db/settings.ts"); +// Import the route under test +const route = await import("../../src/app/api/settings/obsidian/webdav/route.ts"); +// Import DB module to inspect raw stored values +const obsidianDb = await import("../../src/lib/db/obsidian.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(url: string, options?: RequestInit): NextRequest { + return new Request(url, options) as unknown as NextRequest; +} + +test.beforeEach(async () => { + delete process.env.INITIAL_PASSWORD; + delete process.env.STORAGE_ENCRYPTION_KEY; + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + if (ORIGINAL_INITIAL_PASSWORD === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + } + if (ORIGINAL_JWT_SECRET === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; + } + if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) { + delete process.env.STORAGE_ENCRYPTION_KEY; + } else { + process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY; + } +}); + +// โ”€โ”€ Auth is disabled by default (requireLogin not set) so requests succeed โ”€โ”€ + +test("GET with no config โ†’ webdavEnabled:false, all creds null", async () => { + const req = makeRequest("http://localhost/api/settings/obsidian/webdav"); + const res = await route.GET(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.equal(body.webdavEnabled, false); + assert.equal(body.webdavUsername, null); + assert.equal(body.webdavPassword, null); + assert.equal(body.vaultPath, null); +}); + +test("POST with a valid temp dir โ†’ returns { username, password }, GET shows enabled", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-")); + try { + const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: vaultDir }), + }); + const res = await route.POST(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.ok(typeof body.username === "string" && (body.username as string).length > 0, "username non-empty"); + assert.ok(typeof body.password === "string" && (body.password as string).length > 0, "password non-empty"); + assert.ok(typeof body.vaultPath === "string", "vaultPath returned"); + + // GET should now reflect enabled state + const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav"); + const getRes = await route.GET(getReq); + assert.equal(getRes.status, 200); + const getBody = (await getRes.json()) as Record; + assert.equal(getBody.webdavEnabled, true); + assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0); + assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true }); + } +}); + +test("POST with a non-existent path โ†’ 400, body does NOT contain a stack trace", async () => { + const nonExistentPath = path.join(os.tmpdir(), "omni-nonexistent-vault-" + Date.now()); + const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: nonExistentPath }), + }); + const res = await route.POST(req); + + assert.equal(res.status, 400); + const body = (await res.json()) as Record; + const errorMsg = (body.error as Record | undefined)?.message as string | undefined; + // Must not leak stack trace + assert.ok( + !errorMsg || !errorMsg.includes("at /"), + `Error message should not contain a stack trace, got: ${errorMsg}` + ); +}); + +test("POST with invalid body (missing vaultPath) โ†’ 400", async () => { + const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await route.POST(req); + assert.equal(res.status, 400); +}); + +test("DELETE after enable โ†’ webdavEnabled:false, creds cleared in GET", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault2-")); + try { + // Enable first + const enableReq = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: vaultDir }), + }); + const enableRes = await route.POST(enableReq); + assert.equal(enableRes.status, 200); + + // Delete + const deleteReq = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "DELETE", + }); + const deleteRes = await route.DELETE(deleteReq); + assert.equal(deleteRes.status, 200); + const deleteBody = (await deleteRes.json()) as Record; + assert.equal(deleteBody.success, true); + + // GET should now show disabled + const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav"); + const getRes = await route.GET(getReq); + const getBody = (await getRes.json()) as Record; + assert.equal(getBody.webdavEnabled, false); + assert.equal(getBody.webdavUsername, null); + assert.equal(getBody.webdavPassword, null); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true }); + } +}); + +test("GET when disabled does not leak password even if stale data exists", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault3-")); + try { + // Enable, then disable + const enableReq = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: vaultDir }), + }); + await route.POST(enableReq); + const deleteReq = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "DELETE", + }); + await route.DELETE(deleteReq); + + // GET now: password must be null (not a stale value) + const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav"); + const getRes = await route.GET(getReq); + const getBody = (await getRes.json()) as Record; + assert.equal(getBody.webdavEnabled, false); + assert.equal(getBody.webdavPassword, null); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true }); + } +}); + +// โ”€โ”€ Auth guard tests โ”€โ”€ + +test("Unauthenticated GET โ†’ 401 when auth is required", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await settingsDb.updateSettings({ requireLogin: true, password: "" }); + + const req = makeRequest("http://localhost/api/settings/obsidian/webdav"); + const res = await route.GET(req); + assert.equal(res.status, 401); +}); + +test("Unauthenticated POST โ†’ 401 when auth is required", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault4-")); + try { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await settingsDb.updateSettings({ requireLogin: true, password: "" }); + + const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: vaultDir }), + }); + const res = await route.POST(req); + assert.equal(res.status, 401); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true }); + } +}); + +test("Unauthenticated DELETE โ†’ 401 when auth is required", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await settingsDb.updateSettings({ requireLogin: true, password: "" }); + + const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "DELETE", + }); + const res = await route.DELETE(req); + assert.equal(res.status, 401); +}); + +// โ”€โ”€ Encryption round-trip โ”€โ”€ + +test("encryption round-trip: setWebdavPassword stores encrypted, getWebdavPassword returns plaintext", async () => { + // Enable encryption + process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-webdav-route-tests"; + + // Invalidate cached encryption keys so the new env var is picked up. + // The encryption module caches keys in module-level vars; we reset via db instance. + core.resetDbInstance(); + + const plaintext = "super-secret-webdav-password-12345"; + obsidianDb.setWebdavPassword(plaintext); + + // Inspect raw DB row โ€” it must NOT be the plaintext + const db = core.getDbInstance(); + type KVRow = { value: string }; + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("obsidian", "webdav_password") as KVRow | undefined; + + assert.ok(row !== undefined, "Row should exist"); + // The stored JSON string โ€” parse to get inner value + const storedInner = JSON.parse(row!.value) as string; + assert.notEqual( + storedInner, + plaintext, + "Raw DB value must NOT be plaintext when encryption is enabled" + ); + assert.ok( + storedInner.startsWith("enc:v1:"), + `Raw DB value should start with enc:v1: prefix, got: ${storedInner.slice(0, 40)}` + ); + + // getWebdavPassword must round-trip back to plaintext + const retrieved = obsidianDb.getWebdavPassword(); + assert.equal(retrieved, plaintext, "getWebdavPassword must return original plaintext"); + + // Clean up env for other tests + delete process.env.STORAGE_ENCRYPTION_KEY; + core.resetDbInstance(); +}); + +test("encryption graceful fallback: plaintext stored without key reads back correctly", async () => { + // No encryption key set โ€” store plaintext + const plaintext = "plaintext-webdav-password"; + obsidianDb.setWebdavPassword(plaintext); + + // Must read back the same value + const retrieved = obsidianDb.getWebdavPassword(); + assert.equal(retrieved, plaintext, "Plaintext value must read back unchanged when no encryption key"); +});