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 ? (
-
-
- Vault Directory Path
-
-
- setVaultPath(e.target.value)}
- placeholder="/Users/you/Documents/Obsidian"
- disabled={webdavBusy}
- className="font-mono text-sm flex-1"
- />
-
- Enable
-
-
-
- ) : (
-
-
-
cloud_sync
-
-
WebDAV sync enabled
-
{getWebdavUrl()}
-
-
- Disable
-
-
-
-
-
Configure Obsidian Mobile
-
- Install the "WebDAV Sync" plugin, then enter the following:
-
-
-
-
WebDAV URL
-
-
- {getWebdavUrl()}
-
-
-
-
-
-
Username
-
-
- {webdavUsername ?? "โ"}
-
-
-
-
-
-
Password
-
-
-
- {showPassword ? (webdavPassword ?? "โ") : "โขโขโขโขโขโขโขโขโขโขโขโข"}
-
-
-
setShowPassword(!showPassword)}
- variant="secondary"
- size="sm"
- className="shrink-0"
- >
-
- {showPassword ? "visibility_off" : "visibility"}
-
-
-
-
-
-
- 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");
+});