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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-11 15:33:38 -03:00
committed by GitHub
parent 623806b6d7
commit dbbcc65693
6 changed files with 398 additions and 110 deletions

View File

@@ -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)

View File

@@ -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 = []) {

View File

@@ -393,111 +393,6 @@ export default function ObsidianSourceCard() {
</div>
)}
<div className="border-t border-border/50 pt-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Vault Sync (WebDAV)</span>
</div>
<p className="text-[10px] text-text-muted">
Sync your vault to Obsidian mobile using WebDAV over Tailscale.
Install the &quot;WebDAV Sync&quot; plugin on Obsidian mobile.
</p>
{!webdavEnabled ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
Vault Directory Path
</label>
<div className="flex gap-2">
<Input
type="text"
value={vaultPath}
onChange={(e) => setVaultPath(e.target.value)}
placeholder="/Users/you/Documents/Obsidian"
disabled={webdavBusy}
className="font-mono text-sm flex-1"
/>
<Button
onClick={handleEnableWebdav}
loading={webdavBusy}
variant="primary"
size="sm"
>
Enable
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2">
<span className="material-symbols-outlined text-[18px] text-blue-400">cloud_sync</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-300 font-medium">WebDAV sync enabled</p>
<p className="text-[10px] text-blue-400/70 font-mono truncate">{getWebdavUrl()}</p>
</div>
<Button
onClick={handleDisableWebdav}
loading={webdavBusy}
variant="secondary"
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10! shrink-0"
>
Disable
</Button>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border/50 bg-black/10 p-3">
<p className="text-[11px] text-text-muted font-medium">Configure Obsidian Mobile</p>
<p className="text-[10px] text-text-muted">
Install the &quot;WebDAV Sync&quot; plugin, then enter the following:
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">WebDAV URL</label>
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
<code className="text-[10px] text-text-muted font-mono flex-1 break-all select-all">
{getWebdavUrl()}
</code>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">Username</label>
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
{webdavUsername ?? "—"}
</code>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">Password</label>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5 flex-1">
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
{showPassword ? (webdavPassword ?? "—") : "••••••••••••"}
</code>
</div>
<Button
onClick={() => setShowPassword(!showPassword)}
variant="secondary"
size="sm"
className="shrink-0"
>
<span className="material-symbols-outlined text-[16px]">
{showPassword ? "visibility_off" : "visibility"}
</span>
</Button>
</div>
</div>
<p className="text-[10px] text-text-muted">
Use your Tailscale IP instead of localhost when configuring on mobile.
{" "}Both devices must be on the same Tailscale network.
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>

View File

@@ -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 });
}

View File

@@ -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.
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const errorMsg = (body.error as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});