mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 05:47:07 +00:00
feat: configure WebDAV journal remotes
This commit is contained in:
+40
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"webdav">> = {
|
||||
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<BuiltInRemoteConfiguration> {
|
||||
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||
useCouchDBRemoteSetup(registry);
|
||||
useS3RemoteSetup(registry);
|
||||
useWebDAVRemoteSetup(registry);
|
||||
useP2PRemoteSetup(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REMOTE_WEBDAV,
|
||||
isJournalStorageConnectionInspector,
|
||||
type JournalStorageConnectivityResult,
|
||||
type WebDAVSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteWebDAVInitialData,
|
||||
type SetupRemoteWebDAVResultType,
|
||||
type WebDAVSetupMode,
|
||||
} from "./setupDialogTypes";
|
||||
import {
|
||||
summariseAdaptiveCapabilityInspection,
|
||||
webDAVJournalFormFromSettings,
|
||||
webDAVSyncSettingsFromForm,
|
||||
type WebDAVJournalForm,
|
||||
} from "./webDAVJournalSettings";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
let syncSetting = $state<WebDAVJournalForm>(
|
||||
webDAVJournalFormFromSettings({
|
||||
webDAVactiveConnectionURI: "",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
);
|
||||
let setupMode = $state<WebDAVSetupMode>("settings");
|
||||
let error = $state("");
|
||||
let processing = $state(false);
|
||||
let inspection = $state<JournalStorageConnectivityResult | undefined>();
|
||||
let inspectionFingerprint = $state("");
|
||||
let inspectedSettings = $state<WebDAVSyncSetting | undefined>();
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, SetupRemoteWebDAVInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const context = getDialogContext();
|
||||
|
||||
onMount(() => {
|
||||
const initialData = getInitialData?.();
|
||||
if (!initialData) return;
|
||||
setupMode = initialData.mode;
|
||||
try {
|
||||
Object.assign(syncSetting, webDAVJournalFormFromSettings(initialData.settings));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
|
||||
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
|
||||
const isEndpointValid = $derived.by(() => {
|
||||
try {
|
||||
const endpoint = new URL(syncSetting.endpoint.trim());
|
||||
return (
|
||||
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
|
||||
endpoint.search === "" &&
|
||||
endpoint.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const isEndpointInvalid = $derived(syncSetting.endpoint.trim() !== "" && !isEndpointValid);
|
||||
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
|
||||
const inspectionIsCurrent = $derived(
|
||||
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
|
||||
);
|
||||
const adaptiveSummary = $derived.by(() => {
|
||||
if (!inspectionIsCurrent || !inspection?.adaptiveCapabilities) return undefined;
|
||||
return summariseAdaptiveCapabilityInspection(inspection.adaptiveCapabilities);
|
||||
});
|
||||
|
||||
function generateSetting(webDAVSettings: WebDAVSyncSetting): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_JOURNAL_SYNC,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
...webDAVSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function explainUnavailable(result: JournalStorageConnectivityResult): string {
|
||||
if (
|
||||
result.remoteFormat !== undefined &&
|
||||
result.remoteFormat !== "empty" &&
|
||||
result.remoteFormat !== syncSetting.journalFormat
|
||||
) {
|
||||
return translateMessage(
|
||||
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
|
||||
{
|
||||
REMOTE_FORMAT: result.remoteFormat,
|
||||
SELECTED_FORMAT: syncSetting.journalFormat,
|
||||
}
|
||||
);
|
||||
}
|
||||
return translateMessage("The selected WebDAV Journal policy is not supported by this endpoint.");
|
||||
}
|
||||
|
||||
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
throw new Error(translateMessage("Failed to create replicator instance."));
|
||||
}
|
||||
if (!isJournalStorageConnectionInspector(replicator)) {
|
||||
throw new Error(translateMessage("This build cannot inspect WebDAV Journal capabilities."));
|
||||
}
|
||||
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
|
||||
inspection = result;
|
||||
inspectionFingerprint = testedFingerprint;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
error = "";
|
||||
inspection = undefined;
|
||||
inspectionFingerprint = "";
|
||||
inspectedSettings = undefined;
|
||||
processing = true;
|
||||
try {
|
||||
const testedFingerprint = formFingerprint;
|
||||
const candidate = webDAVSyncSettingsFromForm(syncSetting);
|
||||
const trialRemoteSetting = generateSetting(candidate);
|
||||
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
|
||||
if (testedFingerprint !== formFingerprint) return;
|
||||
if (!result.available) {
|
||||
error = explainUnavailable(result);
|
||||
return;
|
||||
}
|
||||
inspectedSettings = candidate;
|
||||
} catch (ex) {
|
||||
error = translateMessage("Error during connection test: ${reason}", {
|
||||
reason: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function commitVerified() {
|
||||
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
|
||||
setResult(inspectedSettings);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
error = "";
|
||||
try {
|
||||
setResult(webDAVSyncSettingsFromForm(syncSetting));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("WebDAV Journal Configuration")} />
|
||||
<Guidance>
|
||||
{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."
|
||||
)}
|
||||
</Guidance>
|
||||
|
||||
<InputRow label={translateMessage("Endpoint URL")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-endpoint"
|
||||
placeholder="https://dav.example/remote.php/dav/files/alice"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^https?://.+"
|
||||
bind:value={syncSetting.endpoint}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isEndpointInsecure}>
|
||||
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={isEndpointInvalid}>
|
||||
{translateMessage("Enter a complete HTTP or HTTPS endpoint without a query string or fragment.")}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Username")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-username"
|
||||
placeholder={translateMessage("Enter your username")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.username}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Password")}>
|
||||
<Password
|
||||
name="webdav-password"
|
||||
placeholder={translateMessage("Enter your password")}
|
||||
bind:value={syncSetting.password}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Collection prefix")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-prefix"
|
||||
placeholder="livesync-journal/"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.prefix}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Use internal API")}>
|
||||
<input type="checkbox" name="webdav-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{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."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Journal data format")}>
|
||||
<select name="webdav-journal-format" bind:value={syncSetting.journalFormat}>
|
||||
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
|
||||
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isAdaptive}>
|
||||
{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."
|
||||
)}
|
||||
</InfoNote>
|
||||
{#if isAdaptive}
|
||||
<InputRow label={translateMessage("Expected repository ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="webdav-expected-repository-id"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.expectedRepositoryId}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Pack retrieval")}>
|
||||
<select name="webdav-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
|
||||
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
|
||||
<option value="range">{translateMessage("Use HTTP Range requests")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote caution>
|
||||
{translateMessage(
|
||||
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
<InputRow label={translateMessage("Custom Headers")}>
|
||||
<textarea
|
||||
name="webdav-custom-headers"
|
||||
placeholder="e.g., x-example-header: value"
|
||||
bind:value={syncSetting.customHeaders}
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
|
||||
{#if adaptiveSummary}
|
||||
{#if adaptiveSummary.required.kind === "verified"}
|
||||
<InfoNote notice>
|
||||
{translateMessage("Required Adaptive operations are supported by this WebDAV endpoint.")}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.required.kind === "unsupported"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", {
|
||||
CAPABILITIES: adaptiveSummary.required.missing.join(", "),
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.required.kind === "failed"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: adaptiveSummary.required.category,
|
||||
RETRY: adaptiveSummary.required.retry,
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else}
|
||||
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
|
||||
{/if}
|
||||
|
||||
{#if adaptiveSummary.byteRange.kind === "verified"}
|
||||
<InfoNote notice>{translateMessage("Exact HTTP byte-range retrieval is supported.")}</InfoNote>
|
||||
{:else if adaptiveSummary.byteRange.kind === "unsupported"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.")}
|
||||
</InfoNote>
|
||||
{:else if adaptiveSummary.byteRange.kind === "failed"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: adaptiveSummary.byteRange.category,
|
||||
RETRY: adaptiveSummary.byteRange.retry,
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"HTTP byte-range retrieval was not checked because the required safety check did not complete."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if inspectionIsCurrent && inspection?.available && !isAdaptive}
|
||||
<InfoNote notice>
|
||||
{translateMessage("WebDAV access and the selected Journal format were verified.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InfoNote>
|
||||
{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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={error !== ""}>{error}</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
|
||||
<Decision
|
||||
title={setupMode === "settings"
|
||||
? translateMessage("Save verified settings")
|
||||
: translateMessage("Continue with verified settings")}
|
||||
important
|
||||
commit={() => commitVerified()}
|
||||
/>
|
||||
<Decision
|
||||
title={isAdaptive
|
||||
? translateMessage("Run endpoint safety check")
|
||||
: translateMessage("Test WebDAV connection")}
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{:else}
|
||||
<Decision
|
||||
title={isAdaptive
|
||||
? translateMessage("Run endpoint safety check")
|
||||
: translateMessage("Test WebDAV connection")}
|
||||
important
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{/if}
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Decision
|
||||
title={translateMessage("Save without connecting")}
|
||||
disabled={!isEndpointValid}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
{/if}
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user