feat: add WebDAV and PostgREST journal remotes

This commit is contained in:
vorotamoroz
2026-07-31 03:21:36 +00:00
parent b21c3114e5
commit 5acad15eba
30 changed files with 1317 additions and 237 deletions
+2 -2
View File
@@ -6,10 +6,10 @@ import {
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
REMOTE_COUCHDB,
REMOTE_MINIO,
type EntryMilestoneInfo,
type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import {
activateRemoteConfiguration,
@@ -59,7 +59,7 @@ async function verifyRemoteState(
return false;
}
milestone = await dbRet.db.get(MILESTONE_DOCID);
} else if (settings.remoteType === REMOTE_MINIO) {
} else if (isJournalRemoteType(settings.remoteType)) {
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
}
+105 -46
View File
@@ -2,7 +2,18 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
REMOTE_WEBDAV,
serialisePostgRESTConnectionURI,
serialiseWebDAVConnectionURI,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
@@ -190,6 +201,36 @@ const protocolFixtures: ProtocolFixture[] = [
expect(settings.region).toBe("ap-northeast-1");
},
},
{
protocol: "webdav",
connectionString: serialiseWebDAVConnectionURI({
endpoint: "https://dav.example/vault",
username: "webdav-user",
password: "webdav-pass",
prefix: "journal/",
useCustomRequestHandler: true,
customHeaders: "x-test: 1",
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_WEBDAV);
expect(settings.webDAVactiveConnectionURI).toContain("sls+webdav://");
},
},
{
protocol: "postgrest",
connectionString: serialisePostgRESTConnectionURI({
endpoint: "https://journal.example",
bearerToken: "signed-token",
vaultId: "vault-1",
schema: "livesync_api",
useCustomRequestHandler: true,
customHeaders: "x-test: 1",
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_POSTGREST);
expect(settings.postgrestActiveConnectionURI).toContain("sls+postgrest://");
},
},
{
protocol: "p2p",
connectionString: ConnectionStringParser.serialize({
@@ -616,55 +657,48 @@ describe("runCommand abnormal cases", () => {
}
);
it.each([
["couchdb", "sls+https://user:pass@example.com:5984/?db=vault"] as const,
[
"s3",
"sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=my-bucket&region=ap-northeast-1",
] as const,
[
"p2p",
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
] as const,
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
const core = createCoreMock();
it.each(protocolFixtures.map(({ protocol, connectionString }) => [protocol, connectionString] as const))(
"remote command round-trip works for %s",
async (_protocol, initialConnStr) => {
const core = createCoreMock();
const addOut = captureStdout(core);
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
...context,
core,
});
expect(addResult).toBe(true);
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
expect(remoteId).not.toBe("");
const addOut = captureStdout(core);
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
...context,
core,
});
expect(addResult).toBe(true);
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
expect(remoteId).not.toBe("");
const export1Out = captureStdout(core);
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export1Result).toBe(true);
const export1Lines = export1Out.lines();
const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : "";
expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr)));
const export1Out = captureStdout(core);
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export1Result).toBe(true);
const export1Lines = export1Out.lines();
const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : "";
expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr)));
const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1));
const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), {
...context,
core,
});
expect(setResult).toBe(true);
const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1));
const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), {
...context,
core,
});
expect(setResult).toBe(true);
const export2Out = captureStdout(core);
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export2Result).toBe(true);
const export2Lines = export2Out.lines();
const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : "";
expect(exported2).toBe(roundTripInput);
});
const export2Out = captureStdout(core);
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export2Result).toBe(true);
const export2Lines = export2Out.lines();
const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : "";
expect(exported2).toBe(roundTripInput);
}
);
describe("runCommand with decoupled vault path", () => {
it("push resolves target path relative to vaultPath, not databasePath", async () => {
@@ -706,6 +740,31 @@ describe("runCommand abnormal cases", () => {
});
describe("mark-resolved and unlock-remote commands", () => {
it.each([REMOTE_WEBDAV, REMOTE_POSTGREST])(
"verifies the Journal milestone after mark-resolved for %s",
async (remoteType) => {
const core = createCoreMock();
const downloadJson = vi.fn(async () => ({
locked: false,
accepted_nodes: ["test-node-id"],
}));
core.services.setting.currentSettings().remoteType = remoteType;
core.services.replicator.getActiveReplicator.mockReturnValue({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => {}),
client: { downloadJson },
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
core,
});
expect(result).toBe(true);
expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json");
}
);
it("mark-resolved without args runs on active database", async () => {
const core = createCoreMock();
const result = await runCommand(makeOptions("mark-resolved", []), {
@@ -8913,6 +8913,12 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
def: "This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.",
zh: "启用设备之间的直接同步。无需服务器,但两台设备必须同时在线,且部分功能可能受限。互联网连接仅用于信令,不用于传输数据。",
},
"Ui.SetupWizard.SetupRemote.PostgRESTOption": {
def: "PostgREST Journal Storage",
},
"Ui.SetupWizard.SetupRemote.PostgRESTOptionDesc": {
def: "Synchronisation using Journal objects stored in PostgreSQL through the packaged PostgREST RPC schema. The server SQL and a Vault-scoped JWT must already be configured.",
},
"Ui.SetupWizard.SetupRemote.ProceedBucket": {
def: "Continue to Object Storage setup",
zh: "继续配置 S3/MinIO/R2",
@@ -8925,10 +8931,22 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
def: "Continue to P2P setup",
zh: "继续配置仅点对点模式",
},
"Ui.SetupWizard.SetupRemote.ProceedPostgREST": {
def: "Continue to PostgREST setup",
},
"Ui.SetupWizard.SetupRemote.ProceedWebDAV": {
def: "Continue to WebDAV setup",
},
"Ui.SetupWizard.SetupRemote.Title": {
def: "Choose a synchronisation remote",
zh: "输入服务器信息",
},
"Ui.SetupWizard.SetupRemote.WebDAVOption": {
def: "WebDAV Journal Storage",
},
"Ui.SetupWizard.SetupRemote.WebDAVOptionDesc": {
def: "Synchronisation using Journal files in a dedicated WebDAV collection. Listing cost grows with the number of retained Journal files.",
},
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": {
def: "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
es: "Nombre único entre dispositivos sincronizados. Para editarlo, desactive sincronización de personalización",
+6
View File
@@ -1100,10 +1100,16 @@
"Ui.SetupWizard.SetupRemote.Guidance": "Select the remote type for this synchronisation setup.",
"Ui.SetupWizard.SetupRemote.P2POption": "Peer-to-Peer (P2P)",
"Ui.SetupWizard.SetupRemote.P2POptionDesc": "This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.",
"Ui.SetupWizard.SetupRemote.PostgRESTOption": "PostgREST Journal Storage",
"Ui.SetupWizard.SetupRemote.PostgRESTOptionDesc": "Synchronisation using Journal objects stored in PostgreSQL through the packaged PostgREST RPC schema. The server SQL and a Vault-scoped JWT must already be configured.",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continue to Object Storage setup",
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": "Continue to CouchDB setup",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to P2P setup",
"Ui.SetupWizard.SetupRemote.ProceedPostgREST": "Continue to PostgREST setup",
"Ui.SetupWizard.SetupRemote.ProceedWebDAV": "Continue to WebDAV setup",
"Ui.SetupWizard.SetupRemote.Title": "Choose a synchronisation remote",
"Ui.SetupWizard.SetupRemote.WebDAVOption": "WebDAV Journal Storage",
"Ui.SetupWizard.SetupRemote.WebDAVOptionDesc": "Synchronisation using Journal files in a dedicated WebDAV collection. Listing cost grows with the number of retained Journal files.",
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
"Use a custom passphrase": "Use a custom passphrase",
"Use a Setup URI (Recommended)": "Use a Setup URI (Recommended)",
+6
View File
@@ -1957,7 +1957,13 @@ Ui:
Guidance: Select the remote type for this synchronisation setup.
P2POption: Peer-to-Peer (P2P)
P2POptionDesc: This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.
PostgRESTOption: PostgREST Journal Storage
PostgRESTOptionDesc: Synchronisation using Journal objects stored in PostgreSQL through the packaged PostgREST RPC schema. The server SQL and a Vault-scoped JWT must already be configured.
ProceedBucket: Continue to Object Storage setup
ProceedCouchDb: Continue to CouchDB setup
ProceedP2P: Continue to P2P setup
ProceedPostgREST: Continue to PostgREST setup
ProceedWebDAV: Continue to WebDAV setup
Title: Choose a synchronisation remote
WebDAVOption: WebDAV Journal Storage
WebDAVOptionDesc: Synchronisation using Journal files in a dedicated WebDAV collection. Listing cost grows with the number of retained Journal files.
+6 -3
View File
@@ -1,4 +1,5 @@
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
import { parseHeaderValues } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -55,8 +56,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
};
}
} else if (settings.remoteType == REMOTE_MINIO) {
responseConfig = { error: "Object Storage Synchronisation" };
} else if (isJournalRemoteType(settings.remoteType)) {
responseConfig = { error: "Journal Storage Synchronisation" };
//
}
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
@@ -81,6 +82,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
pluginConfig.encryptedCouchDBConnection = REDACTED;
pluginConfig.accessKey = REDACTED;
pluginConfig.secretKey = REDACTED;
pluginConfig.webDAVactiveConnectionURI = REDACTED;
pluginConfig.postgrestActiveConnectionURI = REDACTED;
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
const toSchemeOnly = (uri: string) => {
try {
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import {
REMOTE_POSTGREST,
REMOTE_WEBDAV,
serialisePostgRESTConnectionURI,
serialiseWebDAVConnectionURI,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import { generateReport } from "./reportTool";
vi.mock("./utils", () => ({
requestToCouchDBWithCredentials: vi.fn(),
}));
describe("generateReport Journal connection redaction", () => {
it.each([
{
remoteType: REMOTE_WEBDAV,
settingKey: "webDAVactiveConnectionURI",
secret: "webdav-secret",
uri: serialiseWebDAVConnectionURI({
endpoint: "https://dav.example/vault",
username: "alice",
password: "webdav-secret",
prefix: "journal/",
useCustomRequestHandler: false,
customHeaders: "x-private-header: private-value",
}),
},
{
remoteType: REMOTE_POSTGREST,
settingKey: "postgrestActiveConnectionURI",
secret: "signed-jwt-secret",
uri: serialisePostgRESTConnectionURI({
endpoint: "https://journal.example",
bearerToken: "signed-jwt-secret",
vaultId: "private-vault-id",
schema: "livesync_api",
useCustomRequestHandler: false,
customHeaders: "x-private-header: private-value",
}),
},
] as const)("redacts the flat $remoteType connection URI", async (provider) => {
const settings = {
...DEFAULT_SETTINGS,
remoteType: provider.remoteType,
[provider.settingKey]: provider.uri,
};
const core = {
services: {
vault: {
isStorageInsensitive: () => false,
},
},
} as any;
const report = await generateReport(settings, core);
const serialised = JSON.stringify(report);
expect(serialised).not.toContain(provider.secret);
expect(serialised).not.toContain("private-value");
expect(report.pluginConfig[provider.settingKey]).toBe("𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷");
});
});
+4 -3
View File
@@ -1,5 +1,6 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
@@ -9,7 +10,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
if (isJournalRemoteType(settings.remoteType) || settings.remoteType == REMOTE_P2P) {
return Promise.resolve(false);
}
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
@@ -17,7 +18,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_everyAfterResumeProcess(): Promise<boolean> {
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
if (!isJournalRemoteType(this.settings.remoteType) && this.settings.remoteType != REMOTE_P2P) {
const LiveSyncEnabled = this.settings.liveSync;
const continuous = LiveSyncEnabled;
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
@@ -1,4 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_POSTGREST, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/journal-storage";
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
}));
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
@@ -43,6 +49,15 @@ function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isR
}
describe("ModuleReplicatorCouchDB resume replication activity", () => {
it.each([REMOTE_WEBDAV, REMOTE_POSTGREST])("does not claim the %s Journal provider", async (remoteType) => {
const { module } = createModule({
liveSync: false,
syncOnStart: false,
});
expect(await module._anyNewReplicator({ remoteType })).toBe(false);
});
it("exposes start-up one-shot replication as finite replication activity", async () => {
const { module, openReplication, runFiniteReplicationActivity } = createModule({
liveSync: false,
+3 -2
View File
@@ -1,4 +1,5 @@
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
@@ -7,7 +8,7 @@ import { AbstractModule } from "@/modules/AbstractModule";
export class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (settings.remoteType == REMOTE_MINIO) {
if (isJournalRemoteType(settings.remoteType)) {
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
}
return Promise.resolve(false);
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_MINIO, REMOTE_POSTGREST, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/journal-storage";
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
LiveSyncJournalReplicator: class {},
}));
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { ModuleReplicatorMinIO } from "./ModuleReplicatorMinIO.ts";
function createModule(remoteType: string): ModuleReplicatorMinIO {
const services = {
API: {
addCommand: vi.fn(),
addLog: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
registerWindow: vi.fn(),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
};
const core = {
_services: services,
services,
settings: { remoteType },
} as any;
return new ModuleReplicatorMinIO(core);
}
describe("ModuleReplicatorMinIO Journal provider routing", () => {
it.each([REMOTE_MINIO, REMOTE_WEBDAV, REMOTE_POSTGREST])(
"creates the Journal replicator for %s",
async (remoteType) => {
const replicator = await createModule(remoteType)._anyNewReplicator();
expect(replicator).toBeInstanceOf(LiveSyncJournalReplicator);
}
);
it("does not claim CouchDB", async () => {
expect(await createModule(REMOTE_COUCHDB)._anyNewReplicator()).toBe(false);
});
});
@@ -6,13 +6,13 @@ import {
FLAGMD_REDFLAG2_HR,
FLAGMD_REDFLAG3_HR,
REMOTE_COUCHDB,
REMOTE_MINIO,
type ConfigLevel,
LEVEL_POWER_USER,
LEVEL_ADVANCED,
LEVEL_EDGE_CASE,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
@@ -61,8 +61,7 @@ import { panePowerUsers } from "./PanePowerUsers.ts";
import { panePatches } from "./PanePatches.ts";
import { paneMaintenance } from "./PaneMaintenance.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
// For creating a document
@@ -516,22 +515,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
({
visibility: this.isConfiguredAs("remoteType", REMOTE_COUCHDB),
}) as OnUpdateResult;
onlyOnMinIO = () =>
onlyOnJournal = () =>
({
visibility: this.isConfiguredAs("remoteType", REMOTE_MINIO),
visibility: isJournalRemoteType(this.editingSettings.remoteType),
}) as OnUpdateResult;
onlyOnOnlyP2P = () =>
({
visibility: this.isConfiguredAs("remoteType", REMOTE_P2P),
}) as OnUpdateResult;
onlyOnCouchDBOrMinIO = () =>
onlyOnCouchDBOrJournal = () =>
({
visibility:
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) || this.isConfiguredAs("remoteType", REMOTE_MINIO),
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) ||
isJournalRemoteType(this.editingSettings.remoteType),
}) as OnUpdateResult;
// E2EE Function
checkWorkingPassphrase = async (): Promise<boolean> => {
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
if (isJournalRemoteType(this.editingSettings.remoteType)) return true;
const settingForCheck: RemoteDBSettings = {
...this.editingSettings,
@@ -831,18 +831,13 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
});
}
getMinioJournalSyncClient() {
// return new JournalSyncMinio(this.core.settings, this.core.simpleStore, this.core);
// const settings = this.editingSettings as ObsidianLiveSyncSettings;
return new JournalSyncCore(
this.core.settings,
this.core.simpleStore,
this.core,
new MinioStorageAdapter(this.core.settings, this.core)
);
getJournalSyncClient() {
if (!(this.core.replicator instanceof LiveSyncJournalReplicator)) {
throw new Error("The active remote is not a Journal remote");
}
return this.core.replicator.client;
}
async resetRemoteBucket() {
const minioJournal = this.getMinioJournalSyncClient();
await minioJournal.resetBucket();
await this.getJournalSyncClient().resetBucket();
}
}
@@ -81,7 +81,7 @@ export function paneMaintenance(
await this.services.replication.markLocked();
})
)
.addOnUpdate(this.onlyOnCouchDBOrMinIO);
.addOnUpdate(this.onlyOnCouchDBOrJournal);
new Setting(paneEl)
.setName("Emergency restart")
@@ -127,7 +127,7 @@ export function paneMaintenance(
);
});
void addPanel(paneEl, "Syncing", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
void addPanel(paneEl, "Syncing", () => {}, this.onlyOnCouchDBOrJournal).then((paneEl) => {
new Setting(paneEl)
.setName("Resend")
.setDesc("Resend all chunks to the remote.")
@@ -155,7 +155,7 @@ export function paneMaintenance(
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({
await this.getJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
@@ -163,7 +163,7 @@ export function paneMaintenance(
Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
.addOnUpdate(this.onlyOnJournal);
new Setting(paneEl)
.setName("Reset journal sent history")
@@ -176,7 +176,7 @@ export function paneMaintenance(
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({
await this.getJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
lastLocalSeq: 0,
sentIDs: new Set(),
@@ -185,7 +185,7 @@ export function paneMaintenance(
Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
.addOnUpdate(this.onlyOnJournal);
});
void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnCouchDB).then((paneEl) => {
new Setting(paneEl)
@@ -288,94 +288,96 @@ export function paneMaintenance(
// }
// );
void addPanel(paneEl, "Rebuilding Operations (Remote Only)", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
new Setting(paneEl)
.setName("Perform cleanup")
.setDesc(
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."
)
.addButton((button) =>
button
.setButtonText("Perform")
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
Logger(`Cleanup has been began`, LOG_LEVEL_NOTICE, "compaction");
if (await replicator.compactRemote(this.editingSettings)) {
Logger(`Cleanup has been completed!`, LOG_LEVEL_NOTICE, "compaction");
} else {
Logger(`Cleanup has been failed!`, LOG_LEVEL_NOTICE, "compaction");
}
})
)
.addOnUpdate(this.onlyOnCouchDB);
void addPanel(paneEl, "Rebuilding Operations (Remote Only)", () => {}, this.onlyOnCouchDBOrJournal).then(
(paneEl) => {
new Setting(paneEl)
.setName("Perform cleanup")
.setDesc(
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."
)
.addButton((button) =>
button
.setButtonText("Perform")
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
Logger(`Cleanup has been began`, LOG_LEVEL_NOTICE, "compaction");
if (await replicator.compactRemote(this.editingSettings)) {
Logger(`Cleanup has been completed!`, LOG_LEVEL_NOTICE, "compaction");
} else {
Logger(`Cleanup has been failed!`, LOG_LEVEL_NOTICE, "compaction");
}
})
)
.addOnUpdate(this.onlyOnCouchDB);
new Setting(paneEl)
.setName("Overwrite remote")
.setDesc("Overwrite remote with local DB and passphrase.")
.addButton((button) =>
button
.setButtonText("Send")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("remoteOnly");
})
);
new Setting(paneEl)
.setName("Overwrite remote")
.setDesc("Overwrite remote with local DB and passphrase.")
.addButton((button) =>
button
.setButtonText("Send")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("remoteOnly");
})
);
new Setting(paneEl)
.setName("Reset all journal counter")
.setDesc("Initialise all journal history, On the next sync, every item will be received and sent.")
.addButton((button) =>
button
.setButtonText("Reset all")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().resetCheckpointInfo();
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName("Reset all journal counter")
.setDesc("Initialise all journal history, On the next sync, every item will be received and sent.")
.addButton((button) =>
button
.setButtonText("Reset all")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getJournalSyncClient().resetCheckpointInfo();
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournal);
new Setting(paneEl)
.setName("Purge all journal counter")
.setDesc("Purge all download/upload cache.")
.addButton((button) =>
button
.setButtonText("Reset all")
.setWarning()
.setDisabled(false)
.onClick(() => {
this.getMinioJournalSyncClient().resetAllCaches();
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName("Purge all journal counter")
.setDesc("Purge all download/upload cache.")
.addButton((button) =>
button
.setButtonText("Reset all")
.setWarning()
.setDisabled(false)
.onClick(() => {
this.getJournalSyncClient().resetAllCaches();
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournal);
new Setting(paneEl)
.setName("Fresh Start Wipe")
.setDesc("Delete all data on the remote server.")
.addButton((button) =>
button
.setButtonText("Delete")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
lastLocalSeq: 0,
sentIDs: new Set(),
sentFiles: new Set(),
}));
await this.resetRemoteBucket();
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
});
new Setting(paneEl)
.setName("Fresh Start Wipe")
.setDesc("Delete all data on the remote server.")
.addButton((button) =>
button
.setButtonText("Delete")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.getJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
lastLocalSeq: 0,
sentIDs: new Set(),
sentFiles: new Set(),
}));
await this.resetRemoteBucket();
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnJournal);
}
);
void addPanel(paneEl, "Reset").then((paneEl) => {
new Setting(paneEl)
@@ -6,7 +6,9 @@ import {
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE,
type RemoteType,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_POSTGREST, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/journal-storage";
import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -21,6 +23,7 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
import {
activateRemoteConfiguration,
suggestRemoteConfigurationName,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
@@ -29,6 +32,8 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemotePostgREST from "@/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte";
import SetupRemoteWebDAV from "@/modules/features/SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
@@ -60,9 +65,38 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
if (settings.remoteType === REMOTE_P2P) {
return ConnectionStringParser.serialize({ type: "p2p", settings });
}
if (settings.remoteType === REMOTE_WEBDAV) {
return ConnectionStringParser.serialize({ type: "webdav", settings });
}
if (settings.remoteType === REMOTE_POSTGREST) {
return ConnectionStringParser.serialize({ type: "postgrest", settings });
}
return ConnectionStringParser.serialize({ type: "couchdb", settings });
}
function remoteTypeFromConfiguration(parsed: RemoteConfigurationResult): RemoteType {
switch (parsed.type) {
case "s3":
return REMOTE_MINIO;
case "p2p":
return REMOTE_P2P;
case "webdav":
return REMOTE_WEBDAV;
case "postgrest":
return REMOTE_POSTGREST;
case "couchdb":
return REMOTE_COUCHDB;
}
}
function describeRemoteConfiguration(uri: string): string {
try {
return suggestRemoteConfigurationName(ConnectionStringParser.parse(uri));
} catch {
return "Connection details unavailable";
}
}
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
button.setButtonText(emoji);
button.setTooltip(tooltip, { delay: 10, placement: "top" });
@@ -71,21 +105,6 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
return button;
}
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}`;
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
export function paneRemoteConfig(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -189,7 +208,7 @@ export function paneRemoteConfig(
};
const runRemoteSetup = async (
baseSettings: ObsidianLiveSyncSettings,
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
remoteType?: RemoteType
): Promise<ObsidianLiveSyncSettings | false> => {
const setupManager = this.core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
@@ -201,7 +220,15 @@ export function paneRemoteConfig(
return false;
}
targetRemoteType =
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
method === "bucket"
? REMOTE_MINIO
: method === "p2p"
? REMOTE_P2P
: method === "webdav"
? REMOTE_WEBDAV
: method === "postgrest"
? REMOTE_POSTGREST
: REMOTE_COUCHDB;
}
if (targetRemoteType === REMOTE_MINIO) {
@@ -220,6 +247,25 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
if (targetRemoteType === REMOTE_WEBDAV) {
const webDAVConf = await dialogManager.openWithExplicitCancel(SetupRemoteWebDAV, baseSettings);
if (webDAVConf === "cancelled" || typeof webDAVConf !== "object") {
return false;
}
return { ...baseSettings, ...webDAVConf, remoteType: REMOTE_WEBDAV };
}
if (targetRemoteType === REMOTE_POSTGREST) {
const postgrestConf = await dialogManager.openWithExplicitCancel(
SetupRemotePostgREST,
baseSettings
);
if (postgrestConf === "cancelled" || typeof postgrestConf !== "object") {
return false;
}
return { ...baseSettings, ...postgrestConf, remoteType: REMOTE_POSTGREST };
}
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
@@ -328,7 +374,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");
@@ -349,13 +395,7 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
workSettings.remoteType = remoteTypeFromConfiguration(parsed);
Object.assign(workSettings, parsed.settings);
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
@@ -459,13 +499,7 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
workSettings.remoteType = remoteTypeFromConfiguration(parsed);
Object.assign(workSettings, parsed.settings);
const newTweaks =
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
@@ -1,4 +1,10 @@
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
pickBucketSyncSettings,
pickCouchDBSyncSettings,
pickP2PSyncSettings,
pickPostgRESTSyncSettings,
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.
@@ -13,5 +19,7 @@ export function syncActivatedRemoteSettings(
...pickBucketSyncSettings(source),
...pickCouchDBSyncSettings(source),
...pickP2PSyncSettings(source),
...pickWebDAVSyncSettings(source),
...pickPostgRESTSyncSettings(source),
});
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_POSTGREST, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/journal-storage";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
describe("syncActivatedRemoteSettings", () => {
@@ -80,4 +81,37 @@ describe("syncActivatedRemoteSettings", () => {
expect(target.couchDB_PASSWORD).toBe("current-pass");
expect(target.couchDB_DBNAME).toBe("current-db");
});
it.each([
{
activeConfigurationId: "remote-webdav",
remoteType: REMOTE_WEBDAV,
settingKey: "webDAVactiveConnectionURI",
uri: "sls+webdav://alice:secret@dav.example/vault?prefix=journal%2F",
},
{
activeConfigurationId: "remote-postgrest",
remoteType: REMOTE_POSTGREST,
settingKey: "postgrestActiveConnectionURI",
uri: "sls+postgrest://:token@journal.example?vaultId=vault-a&schema=livesync_api",
},
] as const)("should copy the active $remoteType URI into the editing buffer", (provider) => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
};
const source = {
...DEFAULT_SETTINGS,
remoteType: provider.remoteType,
activeConfigurationId: provider.activeConfigurationId,
[provider.settingKey]: provider.uri,
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(provider.remoteType);
expect(target.activeConfigurationId).toBe(provider.activeConfigurationId);
expect(target[provider.settingKey]).toBe(provider.uri);
});
});
+76
View File
@@ -8,6 +8,12 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
REMOTE_WEBDAV,
type PostgRESTSyncSetting,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -23,6 +29,8 @@ import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemotePostgREST from "./SetupWizard/dialogs/SetupRemotePostgREST.svelte";
import SetupRemoteWebDAV from "./SetupWizard/dialogs/SetupRemoteWebDAV.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -36,7 +44,9 @@ import type {
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemotePostgRESTResultType,
SetupRemoteResultType,
SetupRemoteWebDAVResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
import {
@@ -227,6 +237,68 @@ export class SetupManager extends AbstractModule {
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for WebDAV Journal storage.
* @param userMode
* @param currentSetting
* @param activate Whether to activate WebDAV as the main remote
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onWebDAVManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const webDAVConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteWebDAVResultType,
WebDAVSyncSetting
>(SetupRemoteWebDAV, currentSetting);
if (webDAVConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...webDAVConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_WEBDAV;
}
upsertRemoteConfigurationInPlace(newSetting, "webdav", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for PostgREST Journal storage.
* @param userMode
* @param currentSetting
* @param activate Whether to activate PostgREST as the main remote
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onPostgRESTManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const postgrestConf = await this.dialogManager.openWithExplicitCancel<
SetupRemotePostgRESTResultType,
PostgRESTSyncSetting
>(SetupRemotePostgREST, currentSetting);
if (postgrestConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...postgrestConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_POSTGREST;
}
upsertRemoteConfigurationInPlace(newSetting, "postgrest", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for P2P
* @param userMode
@@ -317,6 +389,10 @@ export class SetupManager extends AbstractModule {
return await this.onBucketManualSetup(userMode, currentSetting, true);
} else if (method === "p2p") {
return await this.onP2PManualSetup(userMode, currentSetting, true);
} else if (method === "webdav") {
return await this.onWebDAVManualSetup(userMode, currentSetting, true);
} else if (method === "postgrest") {
return await this.onPostgRESTManualSetup(userMode, currentSetting, true);
} else if (method === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
if (userMode !== UserMode.Unknown) {
@@ -8,6 +8,12 @@ import {
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 {
REMOTE_POSTGREST,
REMOTE_WEBDAV,
serialisePostgRESTConnectionURI,
serialiseWebDAVConnectionURI,
} from "@vrtmrz/livesync-commonlib/journal-storage";
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
@@ -21,6 +27,8 @@ 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/SetupRemoteP2P.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemotePostgREST.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => ({
@@ -147,6 +155,50 @@ describe("SetupManager", () => {
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
});
it("registers WebDAV and PostgREST manual settings as named remote profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const confirmApply = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
const webDAVactiveConnectionURI = serialiseWebDAVConnectionURI({
endpoint: "https://dav.example/vault",
username: "alice",
password: "secret",
prefix: "journal/",
useCustomRequestHandler: false,
customHeaders: "",
});
dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ webDAVactiveConnectionURI });
await manager.onWebDAVManualSetup(UserMode.Update, setting.currentSettings(), true);
const webDAVSettings = confirmApply.mock.calls[0][0];
expect(webDAVSettings.remoteType).toBe(REMOTE_WEBDAV);
expect(Object.values(webDAVSettings.remoteConfigurations)).toEqual([
expect.objectContaining({
uri: expect.stringMatching(/^sls\+webdav:/u),
}),
]);
const postgrestActiveConnectionURI = serialisePostgRESTConnectionURI({
endpoint: "https://journal.example",
bearerToken: "token",
vaultId: "vault-a",
schema: "livesync_api",
useCustomRequestHandler: false,
customHeaders: "",
});
dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ postgrestActiveConnectionURI });
await manager.onPostgRESTManualSetup(UserMode.Update, setting.currentSettings(), true);
const postgrestSettings = confirmApply.mock.calls[1][0];
expect(postgrestSettings.remoteType).toBe(REMOTE_POSTGREST);
expect(Object.values(postgrestSettings.remoteConfigurations)).toEqual([
expect.objectContaining({
uri: expect.stringMatching(/^sls\+postgrest:/u),
}),
]);
});
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
@@ -11,6 +11,8 @@
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_POSTGREST,
TYPE_WEBDAV,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
@@ -27,12 +29,22 @@
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
} else if (userType === TYPE_P2P) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
} else if (userType === TYPE_WEBDAV) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedWebDAV");
} else if (userType === TYPE_POSTGREST) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedPostgREST");
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
return (
userType === TYPE_COUCHDB ||
userType === TYPE_BUCKET ||
userType === TYPE_P2P ||
userType === TYPE_WEBDAV ||
userType === TYPE_POSTGREST
);
});
</script>
@@ -59,6 +71,20 @@
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
<Option
selectedValue={TYPE_WEBDAV}
title={translateMessage("Ui.SetupWizard.SetupRemote.WebDAVOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.WebDAVOptionDesc")}
</Option>
<Option
selectedValue={TYPE_POSTGREST}
title={translateMessage("Ui.SetupWizard.SetupRemote.PostgRESTOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.PostgRESTOptionDesc")}
</Option>
</Options>
</Instruction>
<UserDecisions>
@@ -0,0 +1,223 @@
<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/settings";
import {
REMOTE_POSTGREST,
parsePostgRESTConnectionURI,
serialisePostgRESTConnectionURI,
type PostgRESTConnection,
type PostgRESTSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import { TYPE_CANCELLED, type SetupRemotePostgRESTResultType } from "./setupDialogTypes";
const connection = $state<PostgRESTConnection>({
endpoint: "",
bearerToken: "",
vaultId: "",
schema: "livesync_api",
useCustomRequestHandler: false,
customHeaders: "",
});
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, PostgRESTSyncSetting>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialURI = getInitialData?.()?.postgrestActiveConnectionURI;
if (!initialURI) return;
try {
Object.assign(connection, parsePostgRESTConnectionURI(initialURI));
} catch {
// The form remains editable when an older or malformed value is supplied.
}
});
let error = $state("");
let processing = $state(false);
function normalisedConnection(): PostgRESTConnection {
return {
...connection,
endpoint: connection.endpoint.trim(),
bearerToken: connection.bearerToken.trim(),
vaultId: connection.vaultId.trim(),
schema: connection.schema.trim(),
};
}
function isConnectionValid(): boolean {
const value = normalisedConnection();
if (!value.endpoint || !value.bearerToken || !value.vaultId || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value.schema)) {
return false;
}
try {
serialisePostgRESTConnectionURI(value);
return true;
} catch {
return false;
}
}
const canProceed = $derived.by(isConnectionValid);
const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://"));
const hasInvalidInput = $derived.by(
() =>
(connection.endpoint.trim() !== "" ||
connection.bearerToken.trim() !== "" ||
connection.vaultId.trim() !== "") &&
!canProceed
);
function generateSetting(): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_POSTGREST,
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI(normalisedConnection()),
};
}
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) return "Failed to create a Journal replicator.";
try {
return (await replicator.tryConnectRemote(trialRemoteSetting, false))
? ""
: "Failed to connect to PostgREST. Please check the endpoint, token, Vault ID, schema, and server SQL.";
} catch (ex) {
return `Failed to connect to PostgREST: ${ex}`;
}
} finally {
processing = false;
}
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
setResult({ postgrestActiveConnectionURI: generateSetting().postgrestActiveConnectionURI });
}
} catch (ex) {
error = `Error during connection test: ${ex}`;
}
}
function commit() {
setResult({ postgrestActiveConnectionURI: generateSetting().postgrestActiveConnectionURI });
}
</script>
<DialogHeader title="PostgREST Journal Configuration" />
<Guidance>
Configure the LiveSync Journal RPC schema exposed by PostgREST. This is a Journal object transport, not a CouchDB
replacement or direct table editor.
</Guidance>
<InputRow label="PostgREST Endpoint URL">
<input
type="text"
name="postgrest-endpoint"
placeholder="https://journal.example"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={connection.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>Secure HTTPS connections are required on Obsidian Mobile.</InfoNote>
<InputRow label="Bearer Token">
<Password
name="postgrest-bearer-token"
placeholder="Vault-scoped JWT"
required
bind:value={connection.bearerToken}
/>
</InputRow>
<InputRow label="Vault ID">
<input
type="text"
name="postgrest-vault-id"
placeholder="stable-vault-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={connection.vaultId}
/>
</InputRow>
<InfoNote>
The Vault ID must exactly match the signed `vault_id` claim in the token. Keep it stable when rotating a token so
that the Journal checkpoint and PostgreSQL row ownership remain unchanged.
</InfoNote>
<InputRow label="API Schema">
<input
type="text"
name="postgrest-schema"
placeholder="livesync_api"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={connection.schema}
/>
</InputRow>
<InfoNote error visible={hasInvalidInput}>
Supply an HTTP(S) endpoint without a query or fragment, a bearer token, a Vault ID, and a PostgreSQL identifier for
the schema.
</InfoNote>
<InputRow label="Use internal API">
<input type="checkbox" name="postgrest-use-internal-api" bind:checked={connection.useCustomRequestHandler} />
</InputRow>
<InfoNote>
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="Advanced Settings">
<InputRow label="Custom Headers">
<textarea
name="postgrest-custom-headers"
placeholder="e.g., x-example-header: value"
bind:value={connection.customHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
<InfoNote>
The server must have the packaged LiveSync PostgREST SQL installed. The saved connection contains a bearer token
and custom headers; protect exported connection strings as credentials.
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" disabled={!canProceed} commit={() => commit()} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -0,0 +1,203 @@
<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/settings";
import {
REMOTE_WEBDAV,
parseWebDAVConnectionURI,
serialiseWebDAVConnectionURI,
type WebDAVConnection,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import { TYPE_CANCELLED, type SetupRemoteWebDAVResultType } from "./setupDialogTypes";
const connection = $state<WebDAVConnection>({
endpoint: "",
username: "",
password: "",
prefix: "",
useCustomRequestHandler: false,
customHeaders: "",
});
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialURI = getInitialData?.()?.webDAVactiveConnectionURI;
if (!initialURI) return;
try {
Object.assign(connection, parseWebDAVConnectionURI(initialURI));
} catch {
// The form remains editable when an older or malformed value is supplied.
}
});
let error = $state("");
let processing = $state(false);
function normalisedConnection(): WebDAVConnection {
return {
...connection,
endpoint: connection.endpoint.trim(),
prefix: connection.prefix.trim(),
username: connection.username.trim(),
};
}
function isConnectionValid(): boolean {
try {
serialiseWebDAVConnectionURI(normalisedConnection());
return normalisedConnection().endpoint.length > 0;
} catch {
return false;
}
}
const canProceed = $derived.by(isConnectionValid);
const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointInvalid = $derived.by(() => connection.endpoint.trim() !== "" && !canProceed);
function generateSetting(): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_WEBDAV,
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI(normalisedConnection()),
};
}
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) return "Failed to create a Journal replicator.";
try {
return (await replicator.tryConnectRemote(trialRemoteSetting, false))
? ""
: "Failed to connect to the WebDAV collection. Please check the endpoint, credentials, and prefix.";
} catch (ex) {
return `Failed to connect to the WebDAV collection: ${ex}`;
}
} finally {
processing = false;
}
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
setResult({ webDAVactiveConnectionURI: generateSetting().webDAVactiveConnectionURI });
}
} catch (ex) {
error = `Error during connection test: ${ex}`;
}
}
function commit() {
setResult({ webDAVactiveConnectionURI: generateSetting().webDAVactiveConnectionURI });
}
</script>
<DialogHeader title="WebDAV Journal Configuration" />
<Guidance>
Configure a dedicated WebDAV collection for Journal synchronisation. The server must support MKCOL, PUT, GET,
PROPFIND, and DELETE.
</Guidance>
<InputRow label="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={connection.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>Secure HTTPS connections are required on Obsidian Mobile.</InfoNote>
<InfoNote error visible={isEndpointInvalid}>
Enter a complete HTTP or HTTPS endpoint without a query string or fragment.
</InfoNote>
<InputRow label="Username">
<input
type="text"
name="webdav-username"
placeholder="WebDAV username"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={connection.username}
/>
</InputRow>
<InputRow label="Password">
<Password name="webdav-password" placeholder="WebDAV password" bind:value={connection.password} />
</InputRow>
<InputRow label="Collection Prefix">
<input
type="text"
name="webdav-prefix"
placeholder="livesync-journal/"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={connection.prefix}
/>
</InputRow>
<InfoNote>
Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a very long Journal history
increase synchronisation work.
</InfoNote>
<InputRow label="Use internal API">
<input type="checkbox" name="webdav-use-internal-api" bind:checked={connection.useCustomRequestHandler} />
</InputRow>
<InfoNote>
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="Advanced Settings">
<InputRow label="Custom Headers">
<textarea
name="webdav-custom-headers"
placeholder="e.g., x-example-header: value"
bind:value={connection.customHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
<InfoNote>
The saved connection contains credentials and custom headers. LiveSync encrypts it when configuration encryption is
enabled; do not share an exported connection string as ordinary text.
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" disabled={!canProceed} commit={() => commit()} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -4,6 +4,8 @@ import type {
EncryptionSettings,
ObsidianLiveSyncSettings,
P2PConnectionInfo,
PostgRESTSyncSetting,
WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export const TYPE_IDENTICAL = "identical";
@@ -40,6 +42,8 @@ export const TYPE_CLOSE = "close";
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "bucket";
export const TYPE_P2P = "p2p";
export const TYPE_WEBDAV = "webdav";
export const TYPE_POSTGREST = "postgrest";
export type ResultTypeVault =
| typeof TYPE_IDENTICAL
@@ -93,7 +97,13 @@ export type SelectMethodExistingResultType =
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type SetupRemoteResultType =
| typeof TYPE_COUCHDB
| typeof TYPE_BUCKET
| typeof TYPE_P2P
| typeof TYPE_WEBDAV
| typeof TYPE_POSTGREST
| typeof TYPE_CANCELLED;
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
@@ -110,4 +120,8 @@ export type SetupRemoteCouchDBInitialData = {
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
export type SetupRemotePostgRESTResultType = typeof TYPE_CANCELLED | PostgRESTSyncSetting;
export type ScanQRCodeResultType = typeof TYPE_CLOSE;
+4 -8
View File
@@ -147,14 +147,10 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
: req instanceof Request && typeof req.method === "string"
? req.method
: "GET";
if (typeof req !== "string") {
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (req.body) {
body = await new Response(req.body).arrayBuffer();
}
} else {
body = opts?.body as string;
if (opts?.body !== undefined && opts.body !== null) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (typeof req !== "string" && req.body) {
body = await new Response(req.body).arrayBuffer();
}
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
@@ -4,16 +4,17 @@ const mocks = vi.hoisted(() => ({
platform: {
isMobile: false,
},
requestUrl: vi.fn(),
}));
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -65,3 +66,24 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch", () => {
it("converts a binary body supplied with a URL string to an exact ArrayBuffer", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new ArrayBuffer(0),
headers: {},
status: 200,
});
const service = createService({});
const body = new Uint8Array([0, 1, 2, 255]);
await service.nativeFetch("https://journal.example/object", {
body: body as BodyInit,
method: "PUT",
});
const transmittedBody = mocks.requestUrl.mock.calls[0][0].body;
expect(transmittedBody).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(transmittedBody)).toEqual(body);
});
});
+9 -5
View File
@@ -8,7 +8,7 @@ import {
import FetchEverything from "@/modules/features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { TweakValuesShouldMatchedTemplate } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
import type {
@@ -17,7 +17,11 @@ import type {
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import {
activateRemoteConfiguration,
suggestRemoteConfigurationName,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage";
import { isP2PMainRemote } from "@/common/remoteConfiguration";
/**
@@ -61,7 +65,7 @@ async function askAndActivateRemoteDatabase(host: NecessaryServices<"UI" | "sett
"Multiple remote configurations detected. Please select the remote configuration you want to fetch from.";
const options = Object.entries(settings.remoteConfigurations).map(([id, config]) => {
const parsed = ConnectionStringParser.parse(config.uri);
const displayURI = (config.uri.split("@").pop() || "").substring(0, 20) + "..."; // Show only the last part of URI for better readability and privacy.
const displayURI = suggestRemoteConfigurationName(parsed);
return {
name: `${config.name} - ${parsed.type} (${displayURI})`,
id: id,
@@ -164,8 +168,8 @@ export function createFetchAllFlagHandler(
}
const { vault, extra } = method;
const settings = await Promise.resolve(host.services.setting.currentSettings());
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO;
// Journal Storage remotes do not deduplicate chunks while sending.
const makeLocalChunkBeforeSyncAvailable = !isJournalRemoteType(settings.remoteType);
const mapVaultStateToAction = {
identical: {
makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable,
+73 -13
View File
@@ -41,6 +41,11 @@ import {
askSimpleFetchMode,
} from "./redFlag.simpleFetch";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import {
REMOTE_POSTGREST,
REMOTE_WEBDAV,
serialiseWebDAVConnectionURI,
} from "@vrtmrz/livesync-commonlib/journal-storage";
//Mock synchroniseAllFilesBetweenDBandStorage
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", async (importOriginal) => {
const originalModule = (await importOriginal()) as any;
@@ -50,8 +55,10 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", asyn
};
});
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => {
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", async (importOriginal) => {
const originalModule = (await importOriginal()) as any;
return {
...originalModule,
activateRemoteConfiguration: vi.fn((settings: any, configurationId: string) => {
if (!settings?.remoteConfigurations?.[configurationId]) return false;
return {
@@ -579,6 +586,40 @@ describe("Red Flag Feature", () => {
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
});
it("does not expose unauthenticated WebDAV headers in the remote selection", async () => {
const host = createHostMock();
const log = createLoggerMock();
const privateHeader = "x-private-header: private-value";
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
Object.assign(host.mocks.setting.settings, {
remoteConfigurations: {
alpha: {
name: "Alpha",
uri: serialiseWebDAVConnectionURI({
endpoint: "https://dav.example/alpha",
username: "",
password: "",
prefix: "",
useCustomRequestHandler: false,
customHeaders: privateHeader,
}),
},
beta: {
name: "Beta",
uri: "sls+https://user:pass@example.com/db2",
},
},
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
await createFetchAllFlagHandler(host as any, log).handle();
const selections = host.mocks.ui.confirm.askSelectStringDialogue.mock.calls[0]?.[1] as string[];
expect(selections.join("\n")).not.toContain("private-value");
expect(selections).toContain("Alpha - webdav (WebDAV dav.example)");
});
it("should activate selected remote configuration", async () => {
const host = createHostMock();
const log = createLoggerMock();
@@ -1140,25 +1181,44 @@ describe("Red Flag Feature", () => {
});
});
describe("MinIO configuration handling", () => {
it("should not enable makeLocalChunkBeforeSync when remote is MinIO", () => {
const host = createHostMock();
host.mocks.setting.settings.remoteType = REMOTE_MINIO;
describe("Journal Storage configuration handling", () => {
it.each([REMOTE_MINIO, REMOTE_WEBDAV, REMOTE_POSTGREST])(
"does not prepare deduplicated local chunks for %s",
async (remoteType) => {
const host = createHostMock();
const log = createLoggerMock();
host.mocks.setting.settings.remoteType = remoteType;
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
backup: "backup_skipped",
extra: { preventFetchingConfig: true },
});
const settings = host.mocks.setting.currentSettings();
const isMinIO = settings.remoteType === REMOTE_MINIO;
const result = await createFetchAllFlagHandler(host as any, log).handle();
expect(isMinIO).toBe(true);
});
expect(result).toBe(true);
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalledWith(false, true);
}
);
it("should enable makeLocalChunkBeforeSync for non-MinIO remotes", () => {
it("prepares deduplicated local chunks for CouchDB", async () => {
const host = createHostMock();
const log = createLoggerMock();
host.mocks.setting.settings.remoteType = "CouchDB";
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
backup: "backup_skipped",
extra: { preventFetchingConfig: true },
});
const settings = host.mocks.setting.currentSettings();
const isMinIO = settings.remoteType === REMOTE_MINIO;
const result = await createFetchAllFlagHandler(host as any, log).handle();
expect(isMinIO).toBe(false);
expect(result).toBe(true);
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalledWith(true, true);
});
});