mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-23 11:07:06 +00:00
Add optional Cloudflare TURN credentials and secure profile sharing
This commit is contained in:
@@ -1,105 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
|
||||
import { validateTurnSettings } from "@/integrations/iceServerSources";
|
||||
|
||||
interface Props {
|
||||
host: P2PReplicatorPaneHost;
|
||||
}
|
||||
|
||||
let { host }: Props = $props();
|
||||
let { host }: { host: P2PReplicatorPaneHost } = $props();
|
||||
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
|
||||
const initialSettings = currentSettings();
|
||||
|
||||
let savedTurnServers = $state(initialSettings.P2P_turnServers);
|
||||
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
let turnServers = $state(initialSettings.P2P_turnServers);
|
||||
let turnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let turnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
|
||||
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
|
||||
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
|
||||
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
|
||||
const isModified = $derived(
|
||||
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
|
||||
);
|
||||
function turnSettings(settings: P2PSyncSetting) {
|
||||
return {
|
||||
P2P_turnServers: settings.P2P_turnServers,
|
||||
P2P_turnUsername: settings.P2P_turnUsername,
|
||||
P2P_turnCredential: settings.P2P_turnCredential,
|
||||
P2P_iceServerSource: structuredClone(settings.P2P_iceServerSource),
|
||||
encryptedP2PIceServerSource: settings.encryptedP2PIceServerSource,
|
||||
};
|
||||
}
|
||||
let draft = $state(turnSettings(currentSettings()));
|
||||
let saved = $state(JSON.stringify(turnSettings(currentSettings())));
|
||||
const isModified = $derived(JSON.stringify(draft) !== saved);
|
||||
const sourceError = $derived(validateTurnSettings(draft));
|
||||
|
||||
function loadSettings(settings: P2PSyncSetting): void {
|
||||
savedTurnServers = settings.P2P_turnServers;
|
||||
savedTurnUsername = settings.P2P_turnUsername;
|
||||
savedTurnCredential = settings.P2P_turnCredential;
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
const next = turnSettings(settings);
|
||||
draft = next;
|
||||
saved = JSON.stringify(next);
|
||||
}
|
||||
|
||||
onMount(() =>
|
||||
host.services.context.events.onEvent("setting-saved", (settings) => {
|
||||
loadSettings(settings as P2PSyncSetting);
|
||||
})
|
||||
);
|
||||
onMount(() => host.services.context.events.onEvent("setting-saved", (settings) => loadSettings(settings as P2PSyncSetting)));
|
||||
|
||||
async function save(): Promise<void> {
|
||||
await host.services.setting.applyPartial(
|
||||
{
|
||||
P2P_turnServers: turnServers,
|
||||
P2P_turnUsername: turnUsername,
|
||||
P2P_turnCredential: turnCredential,
|
||||
},
|
||||
true
|
||||
);
|
||||
if (sourceError) return;
|
||||
const values = $state.snapshot(draft);
|
||||
await host.services.setting.updateSettings((settings) => {
|
||||
const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } };
|
||||
const profileId = settings.P2P_ActiveRemoteConfigurationId ||
|
||||
(settings.remoteType === REMOTE_P2P ? settings.activeConfigurationId : "");
|
||||
if (profileId && next.remoteConfigurations[profileId]) {
|
||||
upsertRemoteConfigurationInPlace(next, "p2p", { id: profileId });
|
||||
}
|
||||
return next;
|
||||
}, true);
|
||||
loadSettings(currentSettings());
|
||||
}
|
||||
|
||||
function revert(): void {
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="browser-p2p-transport-settings">
|
||||
<details>
|
||||
<summary>Optional TURN server settings</summary>
|
||||
<p>
|
||||
Configure TURN only when a direct peer-to-peer connection cannot be established.
|
||||
</p>
|
||||
<label class:is-dirty={isTurnServersModified}>
|
||||
<span>TURN Server URLs (comma-separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="turn:turn.example.com:3478"
|
||||
bind:value={turnServers}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnUsernameModified}>
|
||||
<span>TURN Username</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter TURN username"
|
||||
bind:value={turnUsername}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnCredentialModified}>
|
||||
<span>TURN Credential</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter TURN credential"
|
||||
bind:value={turnCredential}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p>
|
||||
<TurnConfiguration bind:settings={draft} />
|
||||
<div class="actions">
|
||||
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
|
||||
<button type="button" class="button mod-cta" disabled={!isModified || !!sourceError} onclick={save}>
|
||||
Save TURN settings
|
||||
</button>
|
||||
<button type="button" class="button" disabled={!isModified} onclick={revert}>
|
||||
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
|
||||
Revert TURN settings
|
||||
</button>
|
||||
</div>
|
||||
@@ -107,27 +63,7 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.browser-p2p-transport-settings {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label.is-dirty {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.browser-p2p-transport-settings { margin-bottom: 1rem; }
|
||||
p { margin: 0.75rem 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type FilePathWithPrefix,
|
||||
@@ -298,7 +298,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
throw new Error("setup requires one argument: <setupURI>");
|
||||
}
|
||||
const setupURI = options.commandArgs[0].trim();
|
||||
if (!setupURI.startsWith(configURIBase)) {
|
||||
if (!setupURI.startsWith(configURIBase) && !setupURI.startsWith(configURIBaseV2)) {
|
||||
throw new Error(`setup URI must start with ${configURIBase}`);
|
||||
}
|
||||
const passphrase = await standardIo.prompt("Enter setup URI passphrase: ");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
@@ -419,6 +419,31 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
|
||||
});
|
||||
|
||||
it("setup imports managed TURN through the versioned encrypted URI", async () => {
|
||||
const core = createCoreMock();
|
||||
const source = {
|
||||
version: 1,
|
||||
id: "cloudflare",
|
||||
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
|
||||
};
|
||||
const passphrase = "setup-passphrase";
|
||||
const setupURI = await processSetting.encodeSettingsToSetupURI(
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_iceServerSource: source,
|
||||
},
|
||||
passphrase
|
||||
);
|
||||
expect(setupURI.startsWith(configURIBaseV2)).toBe(true);
|
||||
expect(setupURI).not.toContain("private-token");
|
||||
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
|
||||
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ P2P_iceServerSource: source }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("setup rejects encoded URI when passphrase is wrong", async () => {
|
||||
const core = createCoreMock();
|
||||
const setupURI = await createSetupURI("correct-passphrase");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||
@@ -524,7 +525,9 @@ export async function main(
|
||||
useOfflineScanner(core);
|
||||
}
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
});
|
||||
// Add target filter to prevent internal files are handled
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
|
||||
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
|
||||
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -217,7 +218,9 @@ export class WebAppRuntime {
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core);
|
||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
});
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
p2p: this.p2p,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
|
||||
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
@@ -70,9 +71,8 @@ export class WebPeerRuntime {
|
||||
isScheduled: () => this.restartScheduled,
|
||||
},
|
||||
});
|
||||
this.p2p = useP2PReplicatorFeature({
|
||||
services: this.services,
|
||||
serviceModules: {},
|
||||
this.p2p = useP2PReplicatorFeature({ services: this.services, serviceModules: {} }, undefined, undefined, {
|
||||
iceServerSources: useIceServerSources(this.services.API.webCompatFetch.bind(this.services.API)),
|
||||
});
|
||||
this.p2pLogCollector = new P2PLogCollector(this.events);
|
||||
this.paneHost = {
|
||||
|
||||
Reference in New Issue
Block a user