mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-23 17:43:20 +00:00
Merge main for Russian catalogue interpolation coverage
This commit is contained in:
commit
2c991bd2f9
148 files changed
+8719
-749
No files matched your search
@@ -7,6 +7,26 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
|
||||
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
|
||||
"TURN configuration": "TURN configuration",
|
||||
Manual: "Manual",
|
||||
"Managed (Cloudflare)": "Managed (Cloudflare)",
|
||||
"TURN Key ID": "TURN Key ID",
|
||||
"TURN Key API Token": "TURN Key API Token",
|
||||
"Unsupported TURN configuration": "Unsupported TURN configuration",
|
||||
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.":
|
||||
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.",
|
||||
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
|
||||
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
"Enter a TURN Key ID.": "Enter a TURN Key ID.",
|
||||
"TURN Key ID contains unsupported characters.": "TURN Key ID contains unsupported characters.",
|
||||
"Enter a TURN Key API Token.": "Enter a TURN Key API Token.",
|
||||
"TURN Key API Token must use Bearer token syntax.": "TURN Key API Token must use Bearer token syntax.",
|
||||
"The selected TURN configuration is not supported.": "The selected TURN configuration is not supported.",
|
||||
|
||||
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
|
||||
@@ -28,8 +48,8 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
|
||||
"Learn more about P2P connections": "Learn more about P2P connections",
|
||||
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
|
||||
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.":
|
||||
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.",
|
||||
"Connection compatibility": "Connection compatibility",
|
||||
"P2P message size": "P2P message size",
|
||||
Standard: "Standard",
|
||||
@@ -184,6 +204,50 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
"ID generation": "ID generation",
|
||||
"Keep current configuration": "Keep current configuration",
|
||||
"Set an ID key": "Set an ID key",
|
||||
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.":
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.",
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.":
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.",
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.":
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.",
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.":
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.",
|
||||
Configured: "Configured",
|
||||
"The saved ID key is configured. Its source cannot be shown again.":
|
||||
"The saved ID key is configured. Its source cannot be shown again.",
|
||||
"Leave this input empty to keep the saved ID key.": "Leave this input empty to keep the saved ID key.",
|
||||
"Generate a random ID key": "Generate a random ID key",
|
||||
"How to set the ID key": "How to set the ID key",
|
||||
"Derive from current E2EE passphrase": "Derive from current E2EE passphrase",
|
||||
"Enter an ID source": "Enter an ID source",
|
||||
"Import an ID recovery code": "Import an ID recovery code",
|
||||
"ID source": "ID source",
|
||||
"ID recovery code": "ID recovery code",
|
||||
"Enter an ID recovery code": "Enter an ID recovery code",
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.":
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.",
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.":
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.",
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.":
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.",
|
||||
"Show current recovery code": "Show current recovery code",
|
||||
"Hide current recovery code": "Hide current recovery code",
|
||||
"Current ID recovery code": "Current ID recovery code",
|
||||
"Copy recovery code": "Copy recovery code",
|
||||
"Recovery code copied.": "Recovery code copied.",
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.":
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.",
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.":
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.",
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.":
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.",
|
||||
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
|
||||
"The ID source or recovery code is invalid. Check it and try again.":
|
||||
"The ID source or recovery code is invalid. Check it and try again.",
|
||||
} as const;
|
||||
|
||||
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { usesEncryptedInternalMetadata } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
@@ -42,7 +43,10 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
return [
|
||||
settings.idDerivationVersion,
|
||||
settings.idDerivationKey,
|
||||
settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
@@ -50,7 +54,8 @@ function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
: (["plain"] as const),
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
usesEncryptedInternalMetadata(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ describe("active Replicator configuration identity", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("replaces a connection when the independent ID key changes", () => {
|
||||
const first = configuredSettings({ idDerivationVersion: 1, idDerivationKey: "a".repeat(64) });
|
||||
const second = { ...first, idDerivationKey: "b".repeat(64) };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
@@ -67,6 +78,22 @@ describe("active Replicator configuration identity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recreates the CouchDB connection when internal Metadata encryption becomes effective", () => {
|
||||
const active = configuredSettings({ usePathObfuscation: true, encryptInternalMetadata: false });
|
||||
const enabled = { ...active, encryptInternalMetadata: true };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(enabled)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
const inactive = { ...active, usePathObfuscation: false };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...inactive, encryptInternalMetadata: true })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(inactive)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(enabled)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
PROVIDER_OWNED_CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
@@ -134,7 +135,7 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_MINIO]: {
|
||||
kind: REMOTE_MINIO,
|
||||
diagnosticName: "Object Storage",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
readiness: PROVIDER_OWNED_CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
|
||||
@@ -47,6 +47,12 @@ describe("central Replicator provider definitions", () => {
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
});
|
||||
|
||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
expect(definitions.get(REMOTE_COUCHDB)?.readiness.centralRemotePreparation).toBe("required");
|
||||
expect(definitions.get(REMOTE_MINIO)?.readiness.centralRemotePreparation).toBe("provider-owned");
|
||||
});
|
||||
|
||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||
|
||||
@@ -285,6 +285,46 @@ describe("replicator probe factories", () => {
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the Security Seed once per resource, including concurrent reads, and refreshes for a new resource", async () => {
|
||||
const settings = createSettings();
|
||||
const factory = createCouchDBSecuritySeedResourceFactory({} as never);
|
||||
const firstResource = await factory(settings);
|
||||
const firstReplicator = mocks.couchDB[0];
|
||||
const firstSeed = new Uint8Array([1]);
|
||||
firstReplicator.getReplicationPBKDF2Salt.mockResolvedValue(firstSeed);
|
||||
|
||||
const [first, concurrent] = await Promise.all([firstResource.read(), firstResource.read()]);
|
||||
expect(first).toBe(firstSeed);
|
||||
expect(concurrent).toBe(firstSeed);
|
||||
await expect(firstResource.read()).resolves.toBe(firstSeed);
|
||||
expect(firstReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledOnce();
|
||||
expect(firstReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith({ ...settings }, true);
|
||||
await firstResource.dispose();
|
||||
|
||||
const nextResource = await factory(settings);
|
||||
const nextReplicator = mocks.couchDB[1];
|
||||
const nextSeed = new Uint8Array([2]);
|
||||
nextReplicator.getReplicationPBKDF2Salt.mockResolvedValue(nextSeed);
|
||||
await expect(nextResource.read()).resolves.toBe(nextSeed);
|
||||
expect(nextReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledOnce();
|
||||
expect(nextReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith({ ...settings }, true);
|
||||
await nextResource.dispose();
|
||||
});
|
||||
|
||||
it("retries a failed Security Seed read within the same resource", async () => {
|
||||
const resource = await createCouchDBSecuritySeedResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
const failure = new Error("connection interrupted");
|
||||
const seed = new Uint8Array([1]);
|
||||
replicator.getReplicationPBKDF2Salt.mockRejectedValueOnce(failure).mockResolvedValueOnce(seed);
|
||||
|
||||
await expect(resource.read()).rejects.toBe(failure);
|
||||
await expect(resource.read()).resolves.toBe(seed);
|
||||
await expect(resource.read()).resolves.toBe(seed);
|
||||
expect(replicator.getReplicationPBKDF2Salt).toHaveBeenCalledTimes(2);
|
||||
await resource.dispose();
|
||||
});
|
||||
|
||||
it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => {
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
|
||||
@@ -21,8 +21,19 @@ function createSecuritySeedResourceFactory(
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = createReplicator();
|
||||
let readPromise: Promise<Uint8Array<ArrayBuffer>> | undefined;
|
||||
const read = () => {
|
||||
if (readPromise) return readPromise;
|
||||
const pending = Promise.resolve().then(() => replicator.getReplicationPBKDF2Salt(snapshot, true));
|
||||
readPromise = pending;
|
||||
// A failed read must not poison a later retry within the same resource.
|
||||
void pending.catch(() => {
|
||||
if (readPromise === pending) readPromise = undefined;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
return Promise.resolve({
|
||||
read: () => replicator.getReplicationPBKDF2Salt(snapshot, true),
|
||||
read,
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redactTurnSettingsForReport } from "./turnSettingsPrivacy";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
|
||||
@@ -67,6 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
redactTurnSettingsForReport(pluginConfig);
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
@@ -78,6 +80,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.idDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedIdDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { generateReport } from "./reportTool";
|
||||
|
||||
vi.mock("./utils", () => ({ requestToCouchDBWithCredentials: vi.fn() }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
|
||||
compatGlobal: { origin: "test", navigator: { userAgent: "test" } },
|
||||
}));
|
||||
|
||||
describe("TURN credentials in diagnostic reports", () => {
|
||||
it("redacts the derived ID key and its encrypted local wrapper", async () => {
|
||||
const key = "f3205cc41d24116d8c2484993c9d9a2e667373af338ba02f2ee71199adb82f2e";
|
||||
const wrapper = "encrypted-id-key-test-wrapper";
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
idDerivationVersion: 1 as const,
|
||||
idDerivationKey: key,
|
||||
encryptedIdDerivationKey: wrapper,
|
||||
};
|
||||
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
|
||||
const report = await generateReport(settings, core);
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(key);
|
||||
expect(text).not.toContain(wrapper);
|
||||
});
|
||||
|
||||
it("redacts provider tokens in all profiles and runtime credentials", async () => {
|
||||
const token = "private+token/with=symbols";
|
||||
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_P2P,
|
||||
...provider,
|
||||
P2P_iceServers: [{ urls: "turn:example.test", username: "issued-user", credential: "issued-password" }],
|
||||
P2P_iceServersExpiresAt: 123456789,
|
||||
remoteConfigurations: {
|
||||
inactive: {
|
||||
id: "inactive",
|
||||
name: "Inactive TURN",
|
||||
isEncrypted: false,
|
||||
uri: `sls+p2p://room?managedType=CF&managedId=private-key&token=${encodeURIComponent(token)}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
|
||||
const report = await generateReport(settings, core);
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(token);
|
||||
expect(text).not.toContain(encodeURIComponent(token));
|
||||
expect(text).not.toContain("private-key");
|
||||
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
|
||||
expect(settings.P2P_managedToken).toBe(token);
|
||||
expect(text).not.toMatch(/issued-user|issued-password|P2P_iceServers/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
hasManagedP2PTurnConfiguration,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
|
||||
|
||||
/** Include inactive profiles when deciding whether Markdown would disclose provider settings. */
|
||||
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
|
||||
return (
|
||||
hasManagedP2PTurnConfiguration(settings) ||
|
||||
Object.values(settings.remoteConfigurations ?? {}).some(({ uri }) => {
|
||||
if (!uri.startsWith("sls+p2p://")) return false;
|
||||
const queryStart = uri.indexOf("?");
|
||||
return (
|
||||
queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("managedType")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports retain a recognised provider label and omit issued credentials. */
|
||||
export function redactTurnSettingsForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
|
||||
if (settings.P2P_managedType) {
|
||||
settings.P2P_managedType =
|
||||
settings.P2P_managedType === CLOUDFLARE_TURN_TYPE ? CLOUDFLARE_TURN_TYPE : "redacted";
|
||||
}
|
||||
if (settings.P2P_managedId !== undefined) settings.P2P_managedId = "redacted";
|
||||
if (settings.P2P_managedToken !== undefined) settings.P2P_managedToken = "redacted";
|
||||
delete settings.P2P_iceServers;
|
||||
delete settings.P2P_iceServersExpiresAt;
|
||||
}
|
||||
|
||||
/** Managed connection profiles are shared through Setup URIs and QR codes. */
|
||||
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
|
||||
delete settings.P2P_iceServers;
|
||||
delete settings.P2P_iceServersExpiresAt;
|
||||
if (!hasManagedTurnSettings(settings)) return;
|
||||
delete settings.P2P_managedType;
|
||||
delete settings.P2P_managedId;
|
||||
delete settings.P2P_managedToken;
|
||||
delete settings.remoteConfigurations;
|
||||
delete settings.activeConfigurationId;
|
||||
delete settings.P2P_ActiveRemoteConfigurationId;
|
||||
}
|
||||
|
||||
/** Preserve the complete connection when Markdown omits its profile group. */
|
||||
export function preserveManagedTurnProfilesOnMarkdownImport(
|
||||
incoming: Partial<ObsidianLiveSyncSettings>,
|
||||
current: ObsidianLiveSyncSettings,
|
||||
merged: ObsidianLiveSyncSettings
|
||||
): void {
|
||||
if (
|
||||
!hasManagedTurnSettings(current) ||
|
||||
incoming.remoteConfigurations !== undefined ||
|
||||
incoming.P2P_managedType !== undefined
|
||||
)
|
||||
return;
|
||||
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
|
||||
merged.activeConfigurationId = current.activeConfigurationId;
|
||||
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
|
||||
Object.assign(merged, pickP2PSyncSettings(current));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
SettingService,
|
||||
type SettingServiceDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import {
|
||||
hasManagedTurnSettings,
|
||||
omitManagedTurnProfilesFromMarkdown,
|
||||
preserveManagedTurnProfilesOnMarkdownImport,
|
||||
redactTurnSettingsForReport,
|
||||
} from "./turnSettingsPrivacy";
|
||||
|
||||
class MemorySettingService extends SettingService {
|
||||
readonly items = new Map<string, string>();
|
||||
saved?: ObsidianLiveSyncSettings;
|
||||
protected setItem(key: string, value: string) {
|
||||
this.items.set(key, value);
|
||||
}
|
||||
protected getItem(key: string) {
|
||||
return this.items.get(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string) {
|
||||
this.items.delete(key);
|
||||
}
|
||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||
this.saved = structuredClone(settings);
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected loadData() {
|
||||
return Promise.resolve(this.saved);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredSettings() {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_managedType: "CF",
|
||||
P2P_managedId: "private-key-id",
|
||||
P2P_managedToken: "private-token",
|
||||
remoteConfigurations: {
|
||||
managed: {
|
||||
id: "managed",
|
||||
name: "Managed TURN",
|
||||
isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=private-key-id&token=private-token",
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "central",
|
||||
P2P_ActiveRemoteConfigurationId: "managed",
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed TURN settings privacy", () => {
|
||||
it("preserves the active managed room through Markdown import, save, and reload", async () => {
|
||||
const current = {
|
||||
...configuredSettings(),
|
||||
remoteType: REMOTE_P2P,
|
||||
activeConfigurationId: "managed",
|
||||
P2P_roomID: "local-room",
|
||||
P2P_relays: "wss://local-relay.example.test",
|
||||
P2P_passphrase: "local-passphrase",
|
||||
};
|
||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||
current.remoteConfigurations.managed.uri = originalURI;
|
||||
const service = new MemorySettingService(new ServiceContext(), {
|
||||
APIService: {
|
||||
getSystemVaultName: () => "test-vault",
|
||||
getAppID: () => "test-app",
|
||||
addLog: () => undefined,
|
||||
confirm: { askString: async () => "" },
|
||||
} as unknown as SettingServiceDependencies["APIService"],
|
||||
});
|
||||
service.settings = structuredClone(current);
|
||||
const incoming: Partial<ObsidianLiveSyncSettings> = {
|
||||
P2P_roomID: "imported-room",
|
||||
P2P_relays: "wss://imported-relay.example.test",
|
||||
P2P_passphrase: "imported-passphrase",
|
||||
};
|
||||
const merged = { ...structuredClone(DEFAULT_SETTINGS), ...incoming };
|
||||
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
|
||||
await service.applyExternalSettings(merged, true);
|
||||
const saved = service.saved!.remoteConfigurations.managed;
|
||||
const uri = saved.isEncrypted ? await service.decryptConfigurationItem(saved.uri, "*") : saved.uri;
|
||||
expect(uri).toBe(originalURI);
|
||||
expect(service.settings.P2P_roomID).toBe("local-room");
|
||||
await service.loadSettings();
|
||||
expect(service.settings.P2P_roomID).toBe("local-room");
|
||||
});
|
||||
|
||||
it("redacts provider fields and issued credentials, including unknown integrations", () => {
|
||||
const settings = configuredSettings();
|
||||
settings.P2P_managedType = "private-token";
|
||||
redactTurnSettingsForReport(settings);
|
||||
expect([settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken]).toEqual([
|
||||
"redacted",
|
||||
"redacted",
|
||||
"redacted",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
|
||||
const settings = configuredSettings();
|
||||
settings.P2P_managedType = "";
|
||||
expect(hasManagedTurnSettings(settings)).toBe(true);
|
||||
omitManagedTurnProfilesFromMarkdown(settings);
|
||||
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
|
||||
expect(settings).not.toHaveProperty("remoteConfigurations");
|
||||
expect(settings).not.toHaveProperty("activeConfigurationId");
|
||||
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
|
||||
});
|
||||
|
||||
it("preserves existing profiles and both selections when Markdown omits the group", () => {
|
||||
const current = configuredSettings();
|
||||
const incoming = { ...DEFAULT_SETTINGS };
|
||||
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
|
||||
delete (incoming as Partial<typeof incoming>).P2P_managedType;
|
||||
const merged = { ...DEFAULT_SETTINGS, ...incoming };
|
||||
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
|
||||
expect(merged.remoteConfigurations).toEqual(current.remoteConfigurations);
|
||||
expect(merged.remoteConfigurations).not.toBe(current.remoteConfigurations);
|
||||
expect(merged.P2P_managedToken).toEqual(current.P2P_managedToken);
|
||||
expect(merged.activeConfigurationId).toBe("central");
|
||||
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
|
||||
});
|
||||
|
||||
it("retains the manual-only Markdown contract", () => {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
const before = structuredClone(settings);
|
||||
omitManagedTurnProfilesFromMarkdown(settings);
|
||||
expect(settings).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePath: vi.fn((path: string) => `normalised(${path})`),
|
||||
path2idBase: vi.fn(async (path: string) => path),
|
||||
id2pathBase: vi.fn((path: string) => path),
|
||||
expandFilePathPrefix: vi.fn((path: string): [string, string] => {
|
||||
if (path.startsWith("i:")) return ["i:", path.substring(2)];
|
||||
return ["", path];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
normalizePath: mocks.normalizePath,
|
||||
Platform: {},
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/string_and_binary/path", () => ({
|
||||
path2id_base: mocks.path2idBase,
|
||||
id2path_base: mocks.id2pathBase,
|
||||
expandFilePathPrefix: mocks.expandFilePathPrefix,
|
||||
isValidFilenameInLinux: vi.fn(),
|
||||
isValidFilenameInDarwin: vi.fn(),
|
||||
isValidFilenameInWidows: vi.fn(),
|
||||
isValidFilenameInAndroid: vi.fn(),
|
||||
stripAllPrefixes: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("path ID normalisation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("normalises the complete path body for %s", async (filename, prefix, body) => {
|
||||
const { path2id } = await import("./utils.ts");
|
||||
|
||||
const result = await path2id(filename as never, false, false);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(mocks.path2idBase).toHaveBeenCalledWith(`${prefix}normalised(${body})`, false, false);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("preserves the path namespace while normalising %s", async (filename, prefix, body) => {
|
||||
mocks.id2pathBase.mockReturnValue(filename);
|
||||
const { id2path } = await import("./utils.ts");
|
||||
|
||||
const result = id2path(filename as never);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
});
|
||||
+5
-8
@@ -7,6 +7,7 @@ import {
|
||||
isValidFilenameInWidows,
|
||||
isValidFilenameInAndroid,
|
||||
stripAllPrefixes,
|
||||
expandFilePathPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
@@ -41,22 +42,18 @@ export async function path2id(
|
||||
obfuscatePassphrase: string | false,
|
||||
caseInsensitive: boolean
|
||||
): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
|
||||
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
||||
return out;
|
||||
}
|
||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||
const filename = id2path_base(id, entry);
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user