diff --git a/docs/settings.md b/docs/settings.md index 6cf14059..9473725b 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -317,7 +317,45 @@ Adaptive Journal can download a complete immutable Pack (`whole-pack`) or reques #### Apply Settings -### 6. CouchDB +### 6. WebDAV Journal + +WebDAV Journal is an experimental remote provider configured when adding (`➕`) or editing (`🔧`) a saved connection profile. Use a dedicated WebDAV collection or prefix rather than mixing Journal objects with unrelated files. + +#### WebDAV connection + +Setting key: webDAVactiveConnectionURI + +The saved `sls+webdav` value contains the HTTP or HTTPS endpoint, optional username and password, collection prefix, custom headers, and internal-API preference. The setup dialogue parses and serialises this value; do not edit it manually or share it as plain text. Only HTTPS endpoints work on Obsidian Mobile. + +**Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS. It is a compatibility option with different behaviour from standard browser fetch. + +#### Journal data format + +Setting key: journalFormat + +`opaque-v1` is the compatible default. `adaptive-v1` uses authenticated immutable Commit Bundles and Packs under a separate remote representation. Existing Opaque Journal data is neither migrated nor read as Adaptive data. Changing formats requires an explicit remote Rebuild or a separate collection prefix. + +#### Expected repository ID + +Setting key: expectedRepositoryId + +This optional base64url identity pins a trusted Adaptive repository. A trusted Setup URI can supply it. Leave it blank only when creating a repository or intentionally trusting the first compatible Adaptive repository reached. + +#### Pack retrieval + +Setting key: packReadPolicy + +Complete Pack retrieval (`whole-pack`) is the portable, throughput-oriented default. Range retrieval (`range`) can reduce transferred bytes, but the endpoint must pass the exact byte-range check. Range support is optional and does not affect the safety of complete Pack retrieval. + +#### Endpoint safety check + +Adaptive setup writes, reads, lists, and removes disposable objects under a random reserved probe prefix. It checks binary fidelity, read-after-write and delete visibility, complete listing of the probe objects, and conditional creation which does not replace an existing object. Exact HTTP byte-range behaviour is reported separately as optional. + +The dialogue reports required operations and Range support independently. Authentication, permission, availability, and invalid-response failures remain explicit failures rather than being reported as unsupported features. A format mismatch requires a remote Rebuild or restoration of the matching profile format. + +Onboarding accepts the profile only after the selected policy passes. Adding or editing a profile in Settings also offers **Save without connecting**, which preserves a locally valid but unverified profile. + +### 7. CouchDB These settings are configured within the CouchDB Setup dialogue when adding (`➕`) or editing (`🔧`) a CouchDB connection profile. @@ -411,7 +449,7 @@ This optional check reads the CouchDB server configuration through Obsidian's in #### Apply Settings -### 7. Peer-to-Peer (P2P) Synchronisation +### 8. Peer-to-Peer (P2P) Synchronisation #### Enable P2P Synchronisation diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 0392fae4..0887733a 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -49,6 +49,54 @@ export const liveSyncProvisionalEnglishMessages = { "Connect to existing database and continue": "Connect to existing database and continue", "Test connection and save": "Test connection and save", "Save without connecting": "Save without connecting", + "WebDAV Journal Configuration": "WebDAV Journal Configuration", + "Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.": + "Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.", + "Collection prefix": "Collection prefix", + "Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.": + "Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.", + "Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.": + "Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.", + "Enter a complete HTTP or HTTPS endpoint without a query string or fragment.": + "Enter a complete HTTP or HTTPS endpoint without a query string or fragment.", + "Use HTTP Range requests": "Use HTTP Range requests", + "Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.": + "Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.", + "The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.": + "The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.", + "Required Adaptive operations are supported by this WebDAV endpoint.": + "Required Adaptive operations are supported by this WebDAV endpoint.", + "The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.": + "The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", + "The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).": + "The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", + "Required Adaptive operations were not checked.": "Required Adaptive operations were not checked.", + "Exact HTTP byte-range retrieval is supported.": "Exact HTTP byte-range retrieval is supported.", + "HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.": + "HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.", + "HTTP byte-range retrieval was not checked because the required safety check did not complete.": + "HTTP byte-range retrieval was not checked because the required safety check did not complete.", + "The selected WebDAV Journal policy is not supported by this endpoint.": + "The selected WebDAV Journal policy is not supported by this endpoint.", + "The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.": + "The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.", + "This build cannot inspect WebDAV Journal capabilities.": "This build cannot inspect WebDAV Journal capabilities.", + "Invalid WebDAV settings: ${REASON}": "Invalid WebDAV settings: ${REASON}", + "The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.": + "The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.", + "WebDAV Journal": "WebDAV Journal", + "Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.": + "Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.", + "Continue to WebDAV setup": "Continue to WebDAV setup", + "Expected repository ID": "Expected repository ID", + "This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.": + "This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.", + "Run endpoint safety check": "Run endpoint safety check", + "Test WebDAV connection": "Test WebDAV connection", + "Continue with verified settings": "Continue with verified settings", + "Save verified settings": "Save verified settings", + "WebDAV access and the selected Journal format were verified.": + "WebDAV access and the selected Journal format were verified.", "Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.", "CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.", diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 54323276..5e242238 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -46,7 +46,6 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings); return defaultRemoteProviderRegistry.serialise(configuration); } - function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) { button.setButtonText(emoji); button.setTooltip(tooltip, { delay: 10, placement: "top" }); @@ -259,7 +258,7 @@ export function paneRemoteConfig( for (const config of Object.values(configs)) { const row = new Setting(listContainer) .setName(config.name) - .setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy + .setDesc(describeRemoteConfiguration(config.uri)); if (config.id === this.editingSettings.activeConfigurationId) { row.nameEl.addClass("sls-active-remote-name"); diff --git a/src/modules/features/SettingDialogue/remoteConfigBuffer.ts b/src/modules/features/SettingDialogue/remoteConfigBuffer.ts index 3d6c23be..f9d903ff 100644 --- a/src/modules/features/SettingDialogue/remoteConfigBuffer.ts +++ b/src/modules/features/SettingDialogue/remoteConfigBuffer.ts @@ -1,4 +1,9 @@ -import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { + pickBucketSyncSettings, + pickCouchDBSyncSettings, + pickP2PSyncSettings, + pickWebDAVSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; // Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys. @@ -11,6 +16,7 @@ export function syncActivatedRemoteSettings( remoteType: source.remoteType, activeConfigurationId: source.activeConfigurationId, ...pickBucketSyncSettings(source), + ...pickWebDAVSyncSettings(source), ...pickCouchDBSyncSettings(source), ...pickP2PSyncSettings(source), }); diff --git a/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts b/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts index 7576aa02..2790fcd8 100644 --- a/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts +++ b/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + REMOTE_MINIO, + REMOTE_WEBDAV, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { syncActivatedRemoteSettings } from "./remoteConfigBuffer"; describe("syncActivatedRemoteSettings", () => { @@ -86,4 +91,34 @@ describe("syncActivatedRemoteSettings", () => { expect(target.couchDB_PASSWORD).toBe("current-pass"); expect(target.couchDB_DBNAME).toBe("current-db"); }); + + it("should copy the active WebDAV connection and Adaptive protocol into the editing buffer", () => { + const target = { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + activeConfigurationId: "old-remote", + webDAVactiveConnectionURI: "sls+webdav://stale.invalid/", + expectedRepositoryId: "", + journalFormat: "opaque-v1" as const, + packReadPolicy: "whole-pack" as const, + }; + const source = { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_WEBDAV, + activeConfigurationId: "remote-webdav", + webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F", + expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + journalFormat: "adaptive-v1" as const, + packReadPolicy: "range" as const, + }; + + syncActivatedRemoteSettings(target, source); + + expect(target.remoteType).toBe(REMOTE_WEBDAV); + expect(target.activeConfigurationId).toBe("remote-webdav"); + expect(target.webDAVactiveConnectionURI).toBe(source.webDAVactiveConnectionURI); + expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId); + expect(target.journalFormat).toBe("adaptive-v1"); + expect(target.packReadPolicy).toBe("range"); + }); }); diff --git a/src/modules/features/SettingDialogue/remoteConfigurationEditor.ts b/src/modules/features/SettingDialogue/remoteConfigurationEditor.ts new file mode 100644 index 00000000..1789a621 --- /dev/null +++ b/src/modules/features/SettingDialogue/remoteConfigurationEditor.ts @@ -0,0 +1,88 @@ +import { + REMOTE_COUCHDB, + REMOTE_MINIO, + REMOTE_P2P, + REMOTE_WEBDAV, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + ConnectionStringParser, + type RemoteConfigurationResult, +} from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage"; + +export type ConfigurableRemoteType = + | typeof REMOTE_COUCHDB + | typeof REMOTE_MINIO + | typeof REMOTE_WEBDAV + | typeof REMOTE_P2P; + +function publicOrigin(uri: string): string { + const url = new URL(uri); + return `${url.protocol}//${url.host}`; +} + +export function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string { + switch (settings.remoteType) { + case REMOTE_COUCHDB: + return ConnectionStringParser.serialize({ type: "couchdb", settings }); + case REMOTE_MINIO: + return ConnectionStringParser.serialize({ type: "s3", settings }); + case REMOTE_WEBDAV: + return ConnectionStringParser.serialize({ type: "webdav", settings }); + case REMOTE_P2P: + return ConnectionStringParser.serialize({ type: "p2p", settings }); + default: + throw new Error("Unsupported remote type"); + } +} + +export function remoteTypeForRemoteConfiguration(parsed: RemoteConfigurationResult): ConfigurableRemoteType { + switch (parsed.type) { + case "couchdb": + return REMOTE_COUCHDB; + case "s3": + return REMOTE_MINIO; + case "webdav": + return REMOTE_WEBDAV; + case "p2p": + return REMOTE_P2P; + } +} + +export function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string { + if (parsed.type === "couchdb") { + try { + const url = new URL(parsed.settings.couchDB_URI); + return `CouchDB ${url.host}`; + } catch { + return "Imported CouchDB"; + } + } + if (parsed.type === "s3") { + return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`; + } + if (parsed.type === "webdav") { + try { + const endpoint = new URL(parseWebDAVConnectionURI(parsed.settings.webDAVactiveConnectionURI).endpoint); + return `WebDAV ${endpoint.host}`; + } catch { + return "Imported WebDAV"; + } + } + return `P2P ${parsed.settings.P2P_roomID || "Remote"}`; +} + +export function describeRemoteConfiguration(uri: string): string { + try { + const parsed = ConnectionStringParser.parse(uri); + if (parsed.type === "couchdb") return publicOrigin(parsed.settings.couchDB_URI); + if (parsed.type === "s3") return publicOrigin(parsed.settings.endpoint); + if (parsed.type === "webdav") { + return publicOrigin(parseWebDAVConnectionURI(parsed.settings.webDAVactiveConnectionURI).endpoint); + } + return "P2P"; + } catch { + return ""; + } +} diff --git a/src/modules/features/SettingDialogue/remoteConfigurationEditor.unit.spec.ts b/src/modules/features/SettingDialogue/remoteConfigurationEditor.unit.spec.ts new file mode 100644 index 00000000..a64b266d --- /dev/null +++ b/src/modules/features/SettingDialogue/remoteConfigurationEditor.unit.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_SETTINGS, + REMOTE_WEBDAV, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { + describeRemoteConfiguration, + remoteTypeForRemoteConfiguration, + serializeRemoteConfiguration, + suggestRemoteConfigurationName, +} from "./remoteConfigurationEditor.ts"; + +describe("remote configuration editor helpers", () => { + it("maps, names, and serialises an Adaptive WebDAV profile", () => { + const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const settings = { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_WEBDAV, + webDAVactiveConnectionURI: + "sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F", + expectedRepositoryId: repositoryId, + journalFormat: "adaptive-v1" as const, + packReadPolicy: "range" as const, + } as ObsidianLiveSyncSettings; + + const uri = serializeRemoteConfiguration(settings); + const parsed = ConnectionStringParser.parse(uri); + + expect(parsed.type).toBe("webdav"); + expect(remoteTypeForRemoteConfiguration(parsed)).toBe(REMOTE_WEBDAV); + expect(suggestRemoteConfigurationName(parsed)).toBe("WebDAV dav.example"); + expect(uri).toContain("journalFormat=adaptive-v1"); + expect(uri).toContain("packReadPolicy=range"); + expect(uri).toContain(`expectedRepositoryId=${repositoryId}`); + }); + + it("does not expose WebDAV credentials or custom headers in the saved-connection description", () => { + const uri = + "sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice" + + "?prefix=notes%2F&headers=Authorization%3A+Bearer+private-token"; + + const description = describeRemoteConfiguration(uri); + + expect(description).toBe("https://dav.example"); + expect(description).not.toContain("alice"); + expect(description).not.toContain("secret"); + expect(description).not.toContain("private-token"); + }); +}); diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index b5b88410..5c9a9446 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -284,6 +284,21 @@ export class SetupManager extends AbstractModule { return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate); } + /** + * Handles manual setup for WebDAV Journal storage. + * @param userMode + * @param currentSetting + * @param activate Whether to activate WebDAV as the main remote type + * @returns Promise that resolves to true if setup completed successfully, false otherwise + */ + async onWebDAVManualSetup( + userMode: UserMode, + currentSetting: ObsidianLiveSyncSettings, + activate = true + ): Promise { + return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate); + } + /** * Handles manual setup for P2P * @param userMode diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index 3df59188..125d7ea7 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -4,11 +4,13 @@ import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, + REMOTE_WEBDAV, type ObsidianLiveSyncSettings, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} })); @@ -21,6 +23,7 @@ vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} }) vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} })); @@ -306,6 +309,44 @@ describe("SetupManager", () => { expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false); }); + it("preserves Adaptive WebDAV fields imported through a Setup URI profile", async () => { + const { manager, setting, dialogManager } = createSetupManager(); + const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const imported = { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + remoteConfigurations: { + webdav: { + id: "webdav", + name: "WebDAV notes", + uri: + "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F" + + `&journalFormat=adaptive-v1&expectedRepositoryId=${repositoryId}&packReadPolicy=range`, + isEncrypted: false, + }, + }, + activeConfigurationId: "webdav", + } as ObsidianLiveSyncSettings; + dialogManager.openWithExplicitCancel + .mockResolvedValueOnce(imported) + .mockResolvedValueOnce("compatible-existing-user"); + + await manager.onUseSetupURI(UserMode.Unknown, "mock-config://webdav-settings"); + + const current = setting.currentSettings(); + expect(current.activeConfigurationId).toBe("webdav"); + const parsed = ConnectionStringParser.parse(current.remoteConfigurations.webdav.uri); + expect(parsed).toMatchObject({ + type: "webdav", + settings: { + webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F", + expectedRepositoryId: repositoryId, + journalFormat: "adaptive-v1", + packReadPolicy: "range", + }, + }); + }); + it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => { const { manager, setting, dialogManager } = createSetupManager(); setting.settings = { @@ -516,6 +557,62 @@ describe("SetupManager", () => { expect(activeProfile?.uri).not.toContain("expectedRepositoryId="); }); + it("adds and activates a manually configured WebDAV profile without replacing existing profiles", async () => { + const { manager, setting, dialogManager } = createSetupManager(); + setting.settings = { + ...setting.currentSettings(), + isConfigured: true, + remoteConfigurations: { + existing: { + id: "existing", + name: "Existing remote", + uri: "sls+http://old:secret@old.example/?db=old", + isEncrypted: false, + }, + }, + activeConfigurationId: "existing", + }; + dialogManager.openWithExplicitCancel + .mockResolvedValueOnce({ + webDAVactiveConnectionURI: + "sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F", + expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + journalFormat: "adaptive-v1", + packReadPolicy: "range", + }) + .mockResolvedValueOnce(true); + + await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings()); + + const current = setting.currentSettings(); + expect(current.remoteType).toBe(REMOTE_WEBDAV); + expect(current.remoteConfigurations.existing).toBeDefined(); + expect(Object.keys(current.remoteConfigurations)).toHaveLength(2); + const activeProfile = current.remoteConfigurations[current.activeConfigurationId]; + expect(activeProfile?.name).toBe("WebDAV dav.example"); + expect(activeProfile?.uri).toContain("sls+webdav://alice:secret@dav.example"); + expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1"); + expect(activeProfile?.uri).toContain("packReadPolicy=range"); + expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + }); + + it.each([ + [UserMode.NewUser, "onboarding"], + [UserMode.ExistingUser, "onboarding"], + [UserMode.Update, "settings"], + ] as const)("passes the %s WebDAV verification policy to the manual setup dialogue", async (userMode, mode) => { + const { manager, setting, dialogManager } = createSetupManager(); + dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + vi.spyOn(manager, "onOnboard").mockResolvedValue(false); + + await manager.onWebDAVManualSetup(userMode, setting.currentSettings()); + + expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), { + settings: setting.currentSettings(), + mode, + }); + }); + it("creates and selects a P2P profile during fresh manual onboarding", async () => { const { manager, setting, dialogManager } = createSetupManager(); setting.settings = { diff --git a/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts b/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts index 2d19fc04..0a0e4297 100644 --- a/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts +++ b/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts @@ -5,11 +5,14 @@ import { $msg as translateMessage } from "@/common/translation"; import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte"; import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte"; import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte"; +import SetupRemoteWebDAV from "./dialogs/SetupRemoteWebDAV.svelte"; import type { SetupRemoteBucketResultType, SetupRemoteCouchDBInitialData, SetupRemoteCouchDBResultType, SetupRemoteP2PResultType, + SetupRemoteWebDAVInitialData, + SetupRemoteWebDAVResultType, } from "./dialogs/setupDialogTypes"; import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry"; @@ -91,10 +94,38 @@ export function useP2PRemoteSetup( return registry.register(descriptor); } +export function useWebDAVRemoteSetup( + registry: RemoteSetupRegistry +): RemoteSetupRegistry { + const descriptor: RemoteSetupProviderDescriptor> = { + type: "webdav", + choice: () => ({ + title: translateMessage("WebDAV Journal"), + description: translateMessage( + "Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check." + ), + proceedTitle: translateMessage("Continue to WebDAV setup"), + }), + open: async ({ dialogManager, intent, settings }) => { + const result = await dialogManager.openWithExplicitCancel< + SetupRemoteWebDAVResultType, + SetupRemoteWebDAVInitialData + >(SetupRemoteWebDAV, { + settings, + mode: intent === "settings" ? "settings" : "onboarding", + }); + return result === "cancelled" ? result : { type: "webdav", settings: result }; + }, + }; + assertSemanticProvider(descriptor.type); + return registry.register(descriptor); +} + export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry { const registry = new RemoteSetupRegistry(); useCouchDBRemoteSetup(registry); useS3RemoteSetup(registry); + useWebDAVRemoteSetup(registry); useP2PRemoteSetup(registry); return registry; } diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte new file mode 100644 index 00000000..bd491cc5 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte @@ -0,0 +1,403 @@ + + + + + {translateMessage( + "Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted." + )} + + + + + + + {translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")} + + + {translateMessage("Enter a complete HTTP or HTTPS endpoint without a query string or fragment.")} + + + + + + + + + + + + + {translateMessage( + "Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work." + )} + + + + + + + {translateMessage( + "Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch." + )} + + + + + + + + {translateMessage( + "Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats." + )} + + {#if isAdaptive} + + + + + {translateMessage( + "This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached." + )} + + + + + + {translateMessage( + "Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check." + )} + + + {translateMessage( + "The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data." + )} + + {/if} + + + + + +{#if adaptiveSummary} + {#if adaptiveSummary.required.kind === "verified"} + + {translateMessage("Required Adaptive operations are supported by this WebDAV endpoint.")} + + {:else if adaptiveSummary.required.kind === "unsupported"} + + {translateMessage("The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", { + CAPABILITIES: adaptiveSummary.required.missing.join(", "), + })} + + {:else if adaptiveSummary.required.kind === "failed"} + + {translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", { + CATEGORY: adaptiveSummary.required.category, + RETRY: adaptiveSummary.required.retry, + })} + + {:else} + {translateMessage("Required Adaptive operations were not checked.")} + {/if} + + {#if adaptiveSummary.byteRange.kind === "verified"} + {translateMessage("Exact HTTP byte-range retrieval is supported.")} + {:else if adaptiveSummary.byteRange.kind === "unsupported"} + + {translateMessage("HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.")} + + {:else if adaptiveSummary.byteRange.kind === "failed"} + + {translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", { + CATEGORY: adaptiveSummary.byteRange.category, + RETRY: adaptiveSummary.byteRange.retry, + })} + + {:else} + + {translateMessage( + "HTTP byte-range retrieval was not checked because the required safety check did not complete." + )} + + {/if} +{/if} +{#if inspectionIsCurrent && inspection?.available && !isAdaptive} + + {translateMessage("WebDAV access and the selected Journal format were verified.")} + +{/if} + + + {translateMessage( + "The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string." + )} + +{error} + +{#if processing} + {translateMessage("Checking connection... Please wait.")} +{:else} + + {#if inspectionIsCurrent && inspection?.available && inspectedSettings} + commitVerified()} + /> + checkConnection()} + /> + {:else} + checkConnection()} + /> + {/if} + {#if setupMode === "settings"} + + {translateMessage( + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected." + )} + + commit()} + /> + {/if} + setResult(TYPE_CANCELLED)} /> + +{/if} diff --git a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts index 416de894..71da2e32 100644 --- a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts +++ b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts @@ -4,6 +4,7 @@ import type { EncryptionSettings, ObsidianLiveSyncSettings, P2PConnectionInfo, + WebDAVSyncSetting, } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry"; @@ -104,6 +105,13 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting; +export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting; +export type WebDAVSetupMode = "onboarding" | "settings"; +export type SetupRemoteWebDAVInitialData = { + settings: WebDAVSyncSetting; + mode: WebDAVSetupMode; +}; + export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection; export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings"; export type SetupRemoteCouchDBInitialData = { diff --git a/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.ts b/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.ts new file mode 100644 index 00000000..9b688c66 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.ts @@ -0,0 +1,107 @@ +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + REMOTE_WEBDAV, + journalProtocolConfigurationForSettings, + parseWebDAVConnectionURI, + serialiseWebDAVConnectionURI, + type AdaptiveJournalPackReadPolicyV1, + type JournalFormatV1, + type JournalStorageAdaptiveCapabilityInspection, + type JournalStorageCapabilityInspection, + type WebDAVConnection, + type WebDAVSyncSetting, +} from "@vrtmrz/livesync-commonlib/journal-storage"; + +export type WebDAVJournalForm = WebDAVConnection & { + expectedRepositoryId: string; + journalFormat: JournalFormatV1; + packReadPolicy: AdaptiveJournalPackReadPolicyV1; +}; + +export type WebDAVCapabilitySummary = + | { kind: "verified" } + | { kind: "not-checked" } + | { kind: "unsupported"; missing: string[] } + | { + kind: "failed"; + category: "authentication" | "invalid-response" | "permission" | "rate-limited" | "unavailable" | "unknown"; + retry: "later" | "never" | "verify-first"; + }; + +export type WebDAVAdaptiveCapabilitySummary = { + byteRange: WebDAVCapabilitySummary; + required: WebDAVCapabilitySummary; +}; + +const emptyWebDAVConnection: WebDAVConnection = { + customHeaders: "", + endpoint: "", + password: "", + prefix: "", + useCustomRequestHandler: false, + username: "", +}; + +function resolveProtocol(settings: WebDAVSyncSetting) { + return journalProtocolConfigurationForSettings({ + ...DEFAULT_SETTINGS, + remoteType: REMOTE_WEBDAV, + ...settings, + }); +} + +export function webDAVJournalFormFromSettings(settings: WebDAVSyncSetting): WebDAVJournalForm { + const connection = settings.webDAVactiveConnectionURI.trim() + ? parseWebDAVConnectionURI(settings.webDAVactiveConnectionURI.trim()) + : emptyWebDAVConnection; + const protocol = resolveProtocol(settings); + return { + ...connection, + ...protocol, + }; +} + +export function webDAVSyncSettingsFromForm(form: WebDAVJournalForm): WebDAVSyncSetting { + const journalFormat = form.journalFormat; + const settings: WebDAVSyncSetting = { + webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({ + customHeaders: form.customHeaders.trim(), + endpoint: form.endpoint.trim(), + password: form.password, + prefix: form.prefix.trim(), + useCustomRequestHandler: form.useCustomRequestHandler, + username: form.username.trim(), + }), + expectedRepositoryId: journalFormat === "adaptive-v1" ? form.expectedRepositoryId.trim() : "", + journalFormat, + packReadPolicy: journalFormat === "adaptive-v1" ? form.packReadPolicy : "whole-pack", + }; + resolveProtocol(settings); + return settings; +} + +function summariseCapabilityInspection(inspection: JournalStorageCapabilityInspection): WebDAVCapabilitySummary { + switch (inspection.status) { + case "verified": + return { kind: "verified" }; + case "not-checked": + return { kind: "not-checked" }; + case "unsupported": + return { kind: "unsupported", missing: [...inspection.missing] }; + case "failed": + return { + category: inspection.failure.category, + kind: "failed", + retry: inspection.failure.retry, + }; + } +} + +export function summariseAdaptiveCapabilityInspection( + inspection: JournalStorageAdaptiveCapabilityInspection +): WebDAVAdaptiveCapabilitySummary { + return { + byteRange: summariseCapabilityInspection(inspection.byteRange), + required: summariseCapabilityInspection(inspection.required), + }; +} diff --git a/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.unit.spec.ts new file mode 100644 index 00000000..843c18d1 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/webDAVJournalSettings.unit.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { serialiseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage"; +import { + summariseAdaptiveCapabilityInspection, + webDAVJournalFormFromSettings, + webDAVSyncSettingsFromForm, +} from "./webDAVJournalSettings.ts"; + +const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +describe("WebDAV Journal settings", () => { + it("round-trips connection fields separately from Adaptive protocol fields", () => { + const settings = { + webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({ + customHeaders: "X-Vault: notes", + endpoint: "https://dav.example/remote.php/dav/files/alice", + password: "p@ss word", + prefix: "vault/notes/", + useCustomRequestHandler: true, + username: "alice@example.com", + }), + expectedRepositoryId: repositoryId, + journalFormat: "adaptive-v1" as const, + packReadPolicy: "range" as const, + }; + + const form = webDAVJournalFormFromSettings(settings); + + expect(form).toEqual({ + customHeaders: "X-Vault: notes", + endpoint: "https://dav.example/remote.php/dav/files/alice", + expectedRepositoryId: repositoryId, + journalFormat: "adaptive-v1", + packReadPolicy: "range", + password: "p@ss word", + prefix: "vault/notes/", + useCustomRequestHandler: true, + username: "alice@example.com", + }); + expect(webDAVSyncSettingsFromForm(form)).toEqual(settings); + }); + + it("normalises editable text and removes Adaptive-only options from Opaque settings", () => { + const settings = webDAVSyncSettingsFromForm({ + customHeaders: " X-Vault: notes ", + endpoint: " http://localhost:8080/dav ", + expectedRepositoryId: ` ${repositoryId} `, + journalFormat: "opaque-v1", + packReadPolicy: "range", + password: " password with spaces ", + prefix: " vault/notes/ ", + useCustomRequestHandler: false, + username: " alice ", + }); + + expect(settings).toEqual({ + webDAVactiveConnectionURI: + "sls+webdav://alice:%20password%20with%20spaces%20@localhost:8080/dav?insecure=true&prefix=vault%2Fnotes%2F&headers=X-Vault%3A+notes", + expectedRepositoryId: "", + journalFormat: "opaque-v1", + packReadPolicy: "whole-pack", + }); + }); + + it("rejects an invalid pinned repository identity", () => { + expect(() => + webDAVSyncSettingsFromForm({ + customHeaders: "", + endpoint: "https://dav.example/vault", + expectedRepositoryId: "AA", + journalFormat: "adaptive-v1", + packReadPolicy: "whole-pack", + password: "secret", + prefix: "", + useCustomRequestHandler: false, + username: "alice", + }) + ).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value"); + }); + + it("presents required capabilities and optional byte-range support independently", () => { + expect( + summariseAdaptiveCapabilityInspection({ + required: { status: "verified" }, + byteRange: { missing: ["byte-range"], status: "unsupported" }, + }) + ).toEqual({ + required: { kind: "verified" }, + byteRange: { kind: "unsupported", missing: ["byte-range"] }, + }); + + expect( + summariseAdaptiveCapabilityInspection({ + required: { + failure: { category: "authentication", retry: "never" }, + status: "failed", + }, + byteRange: { status: "not-checked" }, + }) + ).toEqual({ + required: { category: "authentication", kind: "failed", retry: "never" }, + byteRange: { kind: "not-checked" }, + }); + }); +}); diff --git a/updates.md b/updates.md index 267a6e2d..9feb4437 100644 --- a/updates.md +++ b/updates.md @@ -17,6 +17,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel #### Improved - Object Storage setup can select the experimental Adaptive Journal format and choose complete Pack or verified Range retrieval. Existing Opaque Journal repositories remain the default and require an explicit remote Rebuild before changing formats. +- Saved connections and Setup now support experimental WebDAV Journal profiles. Adaptive WebDAV runs an endpoint safety check, reports required immutable-object behaviour separately from optional Range support, and permits an unverified save only from Settings. ### P2P and experimental browser applications