From 0245929d2deb0c44a25c388b3f10c2595a0b3abd Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 21 Jun 2026 23:16:24 +0800 Subject: [PATCH 1/6] Harden WebDAV chat sync before enabling push Constrain WebDAV transport to HTTPS-only fixed sync endpoints, validate decrypted snapshots, and keep sync scope limited to chat sessions while preserving local conflict safety. Constraint: Desktop IPC must not become a generic HTTP proxy and mobile/desktop sync behavior must stay consistent. Rejected: Allowing renderer-provided WebDAV targets through platform transports | preserves SSRF and redirect bypass risk. Confidence: high Scope-risk: moderate Directive: Keep future sync expansion out of settings, credentials, license, OAuth, and provider-token storage unless a new reviewed snapshot contract is added. Tested: git diff --check; vitest run src/shared/sync-settings.test.ts src/renderer/packages/sync/*.test.ts; biome lint src/shared/sync-settings.test.ts src/shared/sync-webdav.ts src/renderer/packages/sync; tsc --noEmit Not-tested: Full app packaging and live third-party WebDAV interoperability matrix --- src/main/main.ts | 8 +- src/renderer/packages/sync/crypto.test.ts | 22 ++ src/renderer/packages/sync/crypto.ts | 100 ++++++++ src/renderer/packages/sync/index.ts | 6 + src/renderer/packages/sync/local.test.ts | 81 ++++++ src/renderer/packages/sync/local.ts | 68 +++++ src/renderer/packages/sync/service.test.ts | 260 ++++++++++++++++++++ src/renderer/packages/sync/service.ts | 232 +++++++++++++++++ src/renderer/packages/sync/snapshot.test.ts | 160 ++++++++++++ src/renderer/packages/sync/snapshot.ts | 139 +++++++++++ src/renderer/packages/sync/types.ts | 37 +++ src/renderer/packages/sync/webdav.test.ts | 181 ++++++++++++++ src/renderer/packages/sync/webdav.ts | 30 +++ src/renderer/platform/desktop_platform.ts | 5 + src/renderer/platform/interfaces.ts | 4 + src/renderer/platform/mobile_platform.ts | 26 ++ src/renderer/routes/settings/general.tsx | 161 +++++++++++- src/shared/defaults.ts | 11 + src/shared/sync-settings.test.ts | 21 ++ src/shared/sync-webdav.ts | 122 +++++++++ src/shared/types/settings.ts | 31 +++ 21 files changed, 1701 insertions(+), 4 deletions(-) create mode 100644 src/renderer/packages/sync/crypto.test.ts create mode 100644 src/renderer/packages/sync/crypto.ts create mode 100644 src/renderer/packages/sync/index.ts create mode 100644 src/renderer/packages/sync/local.test.ts create mode 100644 src/renderer/packages/sync/local.ts create mode 100644 src/renderer/packages/sync/service.test.ts create mode 100644 src/renderer/packages/sync/service.ts create mode 100644 src/renderer/packages/sync/snapshot.test.ts create mode 100644 src/renderer/packages/sync/snapshot.ts create mode 100644 src/renderer/packages/sync/types.ts create mode 100644 src/renderer/packages/sync/webdav.test.ts create mode 100644 src/renderer/packages/sync/webdav.ts create mode 100644 src/shared/sync-settings.test.ts create mode 100644 src/shared/sync-webdav.ts diff --git a/src/main/main.ts b/src/main/main.ts index df93e33941..f22c4aa1e6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,13 +14,14 @@ import './legacy-database-migration' */ import fs from 'node:fs' -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, Tray } from 'electron' +import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, net, session, shell, Tray } from 'electron' import electronDebug from 'electron-debug' import log from 'electron-log/main' import os from 'os' import path from 'path' // @ts-expect-error - source-map-support doesn't have type definitions import * as sourceMapSupport from 'source-map-support' +import { executeWebDAVRequest, type WebDAVRequest } from 'src/shared/sync-webdav' import type { ShortcutSetting } from 'src/shared/types' import * as analystic from './analystic-node' import { AppUpdater } from './app-updater' @@ -763,6 +764,11 @@ ipcMain.handle('ensureAutoLaunch', (event, enable: boolean) => { return autoLauncher.ensure(enable) }) +ipcMain.handle('webdav:request', async (_event, request: WebDAVRequest) => { + const webdavBaseUrl = getSettings().sync.webdav.url + return executeWebDAVRequest(webdavBaseUrl, request, (input, init) => net.fetch(String(input), init)) +}) + ipcMain.handle('parseFileLocally', async (event, dataJSON: string) => { const params: { filePath: string } = JSON.parse(dataJSON) try { diff --git a/src/renderer/packages/sync/crypto.test.ts b/src/renderer/packages/sync/crypto.test.ts new file mode 100644 index 0000000000..463c9428fd --- /dev/null +++ b/src/renderer/packages/sync/crypto.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' + +describe('sync crypto envelope', () => { + it('round trips JSON with the sync password', async () => { + const payload = { version: 1, sessions: [{ id: 's1', name: 'Hello' }] } + + const envelope = await encryptJsonEnvelope(payload, 'correct horse battery staple') + const decrypted = await decryptJsonEnvelope(envelope, 'correct horse battery staple') + + expect(envelope.version).toBe(1) + expect(envelope.kdf).toBe('PBKDF2-SHA256') + expect(envelope.ciphertext).not.toContain('Hello') + expect(decrypted).toEqual(payload) + }) + + it('rejects a wrong sync password', async () => { + const envelope = await encryptJsonEnvelope({ secret: 'chat history' }, 'right-password') + + await expect(decryptJsonEnvelope(envelope, 'wrong-password')).rejects.toThrow(/decrypt/i) + }) +}) diff --git a/src/renderer/packages/sync/crypto.ts b/src/renderer/packages/sync/crypto.ts new file mode 100644 index 0000000000..2c02548796 --- /dev/null +++ b/src/renderer/packages/sync/crypto.ts @@ -0,0 +1,100 @@ +import type { SyncCryptoEnvelope } from './types' + +const ENVELOPE_VERSION = 1 +const PBKDF2_ITERATIONS = 250_000 +const SALT_BYTES = 16 +const IV_BYTES = 12 + +function getCrypto(): Crypto { + const cryptoImpl = globalThis.crypto + if (!cryptoImpl?.subtle) { + throw new Error('Web Crypto is not available') + } + return cryptoImpl +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +async function deriveAesKey(password: string, salt: Uint8Array, iterations: number): Promise { + if (!password) { + throw new Error('Sync password is required') + } + const cryptoImpl = getCrypto() + const passwordKey = await cryptoImpl.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [ + 'deriveKey', + ]) + return cryptoImpl.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: toArrayBuffer(salt), + iterations, + }, + passwordKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ) +} + +export async function encryptJsonEnvelope(payload: T, password: string): Promise { + const cryptoImpl = getCrypto() + const salt = cryptoImpl.getRandomValues(new Uint8Array(SALT_BYTES)) + const iv = cryptoImpl.getRandomValues(new Uint8Array(IV_BYTES)) + const key = await deriveAesKey(password, salt, PBKDF2_ITERATIONS) + const plaintext = new TextEncoder().encode(JSON.stringify(payload)) + const ciphertext = await cryptoImpl.subtle.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, plaintext) + + return { + version: ENVELOPE_VERSION, + kdf: 'PBKDF2-SHA256', + cipher: 'AES-GCM', + iterations: PBKDF2_ITERATIONS, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + } +} + +export async function decryptJsonEnvelope(envelope: SyncCryptoEnvelope, password: string): Promise { + if (envelope.version !== ENVELOPE_VERSION || envelope.kdf !== 'PBKDF2-SHA256' || envelope.cipher !== 'AES-GCM') { + throw new Error('Unsupported sync encryption envelope') + } + + try { + const cryptoImpl = getCrypto() + const salt = base64ToBytes(envelope.salt) + const iv = base64ToBytes(envelope.iv) + const key = await deriveAesKey(password, salt, envelope.iterations) + const decrypted = await cryptoImpl.subtle.decrypt( + { name: 'AES-GCM', iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(base64ToBytes(envelope.ciphertext)) + ) + return JSON.parse(new TextDecoder().decode(decrypted)) as T + } catch (error) { + throw new Error(`Failed to decrypt sync data: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/src/renderer/packages/sync/index.ts b/src/renderer/packages/sync/index.ts new file mode 100644 index 0000000000..a8603104a5 --- /dev/null +++ b/src/renderer/packages/sync/index.ts @@ -0,0 +1,6 @@ +export * from './crypto' +export * from './local' +export * from './service' +export * from './snapshot' +export * from './types' +export * from './webdav' diff --git a/src/renderer/packages/sync/local.test.ts b/src/renderer/packages/sync/local.test.ts new file mode 100644 index 0000000000..b6ba12e8a5 --- /dev/null +++ b/src/renderer/packages/sync/local.test.ts @@ -0,0 +1,81 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listLocalSyncMetas, listLocalSyncSessions } from './local' + +vi.mock('@/stores/chatStore', () => ({ + listAllSessionsMeta: vi.fn(), +})) + +vi.mock('@/storage', () => ({ + default: { + getItem: vi.fn(), + }, +})) + +const { listAllSessionsMeta } = await import('@/stores/chatStore') +const { default: storage } = await import('@/storage') + +function session(id: string, type?: Session['type']): Session { + return { + id, + type, + name: id, + messages: [], + } +} + +function meta(id: string, type?: SessionMetaRecord['type']): SessionMetaRecord { + return { + id, + type, + name: id, + sortOrder: 1, + createdAt: 1, + } +} + +describe('local sync data selection', () => { + beforeEach(() => { + vi.mocked(listAllSessionsMeta).mockReset() + vi.mocked(storage.getItem).mockReset() + }) + + it('lists only chat and legacy chat sessions for sync', async () => { + vi.mocked(listAllSessionsMeta).mockResolvedValue([ + meta('chat-1', 'chat'), + meta('legacy-chat'), + meta('picture-1', 'picture'), + meta('guide-1', 'guide'), + ]) + vi.mocked(storage.getItem).mockImplementation((key) => { + const id = String(key).replace('session:', '') + return Promise.resolve(session(id, id === 'legacy-chat' ? undefined : 'chat')) + }) + + const sessions = await listLocalSyncSessions() + const metas = await listLocalSyncMetas() + + expect(sessions.map((item) => item.id)).toEqual(['chat-1', 'legacy-chat']) + expect(metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-chat']) + expect(storage.getItem).toHaveBeenCalledTimes(2) + }) + + it('migrates legacy message content before syncing local sessions', async () => { + vi.mocked(listAllSessionsMeta).mockResolvedValue([meta('legacy-chat')]) + vi.mocked(storage.getItem).mockResolvedValue({ + id: 'legacy-chat', + name: 'Legacy', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'legacy text', + }, + ], + } as unknown as Session) + + const sessions = await listLocalSyncSessions() + + expect(sessions[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'legacy text' }]) + }) +}) diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts new file mode 100644 index 0000000000..c42af11f55 --- /dev/null +++ b/src/renderer/packages/sync/local.ts @@ -0,0 +1,68 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { v4 as uuidv4 } from 'uuid' +import platform from '@/platform' +import storage from '@/storage' +import { StorageKeyGenerator } from '@/storage/StoreStorage' +import { getMetaStorage, listAllSessionsMeta, refreshSessionListCache } from '@/stores/chatStore' +import { settingsStore } from '@/stores/settingsStore' +import { migrateSession } from '@/utils/session-utils' +import type { WebDAVSyncDeps } from './service' + +function isChatSessionLike(item: Pick | Pick): boolean { + return item.type === 'chat' || !item.type +} + +export async function listLocalSyncSessions(): Promise { + const metas = (await listAllSessionsMeta()).filter(isChatSessionLike) + const sessions = await Promise.all( + metas.map((meta) => storage.getItem(StorageKeyGenerator.session(meta.id), null)) + ) + return sessions + .filter((session): session is Session => { + if (!session) { + return false + } + return isChatSessionLike(session) + }) + .map((session) => migrateSession(session)) +} + +export async function listLocalSyncMetas(): Promise { + return (await listAllSessionsMeta()).filter(isChatSessionLike) +} + +export async function saveSyncSession(session: Session): Promise { + await storage.setItemNow(StorageKeyGenerator.session(session.id), session) +} + +export async function deleteSyncSession(sessionId: string): Promise { + await storage.removeItem(StorageKeyGenerator.session(sessionId)) +} + +export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { + if (metas.length === 0) { + return + } + const metaStorage = await getMetaStorage() + await metaStorage.createMany(metas) + await refreshSessionListCache() +} + +export function updateSyncLastSyncedAt(isoDate: string) { + settingsStore.getState().setSettings((settings) => { + settings.sync.lastSyncedAt = isoDate + }) +} + +export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { + return { + platform, + listLocalSessions: listLocalSyncSessions, + listLocalMetas: listLocalSyncMetas, + saveSession: saveSyncSession, + deleteSession: deleteSyncSession, + saveMetas: saveSyncMetas, + updateLastSyncedAt: updateSyncLastSyncedAt, + createId: uuidv4, + } +} diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts new file mode 100644 index 0000000000..9e45e6eac3 --- /dev/null +++ b/src/renderer/packages/sync/service.test.ts @@ -0,0 +1,260 @@ +import type { Settings } from '@shared/types' +import { describe, expect, it, vi } from 'vitest' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' +import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' +import type { SyncSnapshot, WebDAVRequest } from './types' + +const baseSettings = { + sync: { + enabled: true, + provider: 'webdav', + webdav: { + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: 'app-password', + syncPassword: 'sync-secret', + }, + }, +} as Settings + +function session(id: string, name: string, text: string) { + return { + id, + type: 'chat' as const, + name, + messages: [ + { + id: `${id}-m1`, + role: 'user' as const, + contentParts: [{ type: 'text' as const, text }], + }, + ], + } +} + +function meta(id: string, name: string, sortOrder = 1) { + return { + id, + name, + type: 'chat' as const, + sortOrder, + createdAt: sortOrder, + } +} + +describe('WebDAV sync service', () => { + it('uploads an encrypted snapshot to the fixed WebDAV path', async () => { + const requests: WebDAVRequest[] = [] + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), + listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + + expect(result.uploaded).toBe(1) + expect(put?.url).toBe('https://dav.example.com/files/me/ChatboxSync/v1/snapshot.json.enc') + expect(put?.headers?.Authorization).toBe(`Basic ${btoa('alice:app-password')}`) + expect(put?.body).not.toContain('hello') + + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + expect(decrypted.sessions.map((item) => item.id)).toEqual(['s1']) + expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:01.000Z') + }) + + it('downloads, decrypts, and saves missing remote sessions', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'hi')], + metas: [meta('remote-1', 'Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.imported).toBe(1) + expect(result.conflicts).toBe(0) + expect(deps.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'remote-1', + messages: remote.sessions[0].messages, + name: 'Remote', + type: 'chat', + }) + ) + expect(deps.saveMetas).toHaveBeenCalledWith(remote.metas) + expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') + }) + + it('rejects plaintext HTTP WebDAV URLs before sending credentials', async () => { + const deps = { + platform: { + webdavRequest: vi.fn(), + }, + listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), + listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(), + } + const settings = { + ...baseSettings, + sync: { + ...baseSettings.sync, + webdav: { + ...baseSettings.sync.webdav, + url: 'http://dav.example.com/files/me/', + }, + }, + } as Settings + + await expect(uploadWebDAVSnapshot(settings, deps)).rejects.toThrow(/https/i) + expect(deps.platform.webdavRequest).not.toHaveBeenCalled() + }) + + it('rejects malformed decrypted remote snapshots', async () => { + const envelope = await encryptJsonEnvelope({ version: 1, sessions: 'not-array', metas: [] }, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async () => ({ + status: 200, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/invalid sync snapshot/i) + expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.saveMetas).not.toHaveBeenCalled() + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + + it('migrates legacy remote messages before saving downloaded sessions', async () => { + const remote = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + { + id: 'legacy-remote', + type: 'chat', + name: 'Legacy Remote', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'legacy remote text', + }, + ], + }, + ], + metas: [meta('legacy-remote', 'Legacy Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(deps.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'legacy-remote', + messages: [expect.objectContaining({ contentParts: [{ type: 'text', text: 'legacy remote text' }] })], + }) + ) + }) + + it('rolls back saved sessions when metadata import fails', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'hi'), session('remote-2', 'Remote 2', 'hello')], + metas: [meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(() => Promise.reject(new Error('meta write failed'))), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/meta write failed/) + + expect(deps.saveSession).toHaveBeenCalledTimes(2) + expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') + expect(deps.deleteSession).toHaveBeenCalledWith('remote-2') + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts new file mode 100644 index 0000000000..7ce7132ff4 --- /dev/null +++ b/src/renderer/packages/sync/service.ts @@ -0,0 +1,232 @@ +import type { Session, SessionMetaRecord, Settings } from '@shared/types' +import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' +import { z } from 'zod' +import { migrateSession } from '@/utils/session-utils' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' +import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import type { SyncCryptoEnvelope, SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' +import { buildBasicAuthHeader, joinWebDAVUrl, requestWebDAV, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH } from './webdav' + +const RawSyncSnapshotSchema = z.object({ + version: z.literal(1), + exportedAt: z.string(), + deviceName: z.string(), + sessions: z.array(z.unknown()), + metas: z.array(SessionMetaRecordSchema), +}) + +type SyncPlatform = { + getDeviceName?: () => Promise + webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise +} + +export type WebDAVSyncDeps = { + platform: SyncPlatform + listLocalSessions: () => Promise + listLocalMetas: () => Promise + saveSession: (session: Session) => Promise + deleteSession?: (sessionId: string) => Promise + saveMetas: (metas: SessionMetaRecord[]) => Promise + updateLastSyncedAt: (isoDate: string) => Promise | void + createId: () => string + now?: () => number +} + +export type UploadWebDAVSnapshotResult = { + uploaded: number + lastSyncedAt: string +} + +export type DownloadWebDAVSnapshotResult = { + imported: number + conflicts: number + saved: number + lastSyncedAt?: string + remoteMissing?: boolean +} + +function getWebDAVSettings(settings: Settings) { + const sync = settings.sync + if (!sync || sync.provider !== 'webdav') { + throw new Error('WebDAV sync is not configured') + } + const { url, username, password, syncPassword } = sync.webdav + if (!url.trim()) { + throw new Error('WebDAV URL is required') + } + if (new URL(url).protocol !== 'https:') { + throw new Error('WebDAV URL must use HTTPS') + } + if (!username.trim()) { + throw new Error('WebDAV username is required') + } + if (!password) { + throw new Error('WebDAV password is required') + } + if (!syncPassword) { + throw new Error('Sync encryption password is required') + } + return sync.webdav +} + +function authHeaders(settings: Settings): Record { + const webdav = getWebDAVSettings(settings) + return { + Authorization: buildBasicAuthHeader(webdav.username, webdav.password), + } +} + +function parseSyncSnapshot(value: unknown): SyncSnapshot { + const result = RawSyncSnapshotSchema.safeParse(value) + if (!result.success) { + throw new Error('Invalid sync snapshot') + } + const sessions = z + .array(SessionSchema) + .safeParse(result.data.sessions.map((session) => migrateSession(session as Session))) + if (!sessions.success) { + throw new Error('Invalid sync snapshot') + } + return { + ...result.data, + sessions: sessions.data, + } +} + +function snapshotUrl(settings: Settings): string { + return joinWebDAVUrl(getWebDAVSettings(settings).url, SYNC_SNAPSHOT_PATH) +} + +function collectionUrls(settings: Settings): string[] { + const webdav = getWebDAVSettings(settings) + return ['ChatboxSync/', SYNC_COLLECTION_PATH].map((path) => joinWebDAVUrl(webdav.url, path)) +} + +function assertSuccess(response: WebDAVResponse, action: string, okStatuses: number[]) { + if (!okStatuses.includes(response.status)) { + throw new Error(`${action} failed with HTTP ${response.status}${response.body ? `: ${response.body}` : ''}`) + } +} + +async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatform) { + const webdav = getWebDAVSettings(settings) + const headers = authHeaders(settings) + for (const url of collectionUrls(settings)) { + const response = await requestWebDAV(platform, webdav.url, { + url, + method: 'MKCOL', + headers, + }) + assertSuccess(response, 'Create WebDAV sync directory', [200, 201, 405]) + } +} + +export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { + const webdav = getWebDAVSettings(settings) + await ensureWebDAVCollections(settings, deps.platform) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PROPFIND', + headers: { + ...authHeaders(settings), + Depth: '0', + }, + }) + assertSuccess(response, 'Check WebDAV snapshot', [200, 207, 404]) +} + +export async function uploadWebDAVSnapshot( + settings: Settings, + deps: WebDAVSyncDeps +): Promise { + const webdav = getWebDAVSettings(settings) + await ensureWebDAVCollections(settings, deps.platform) + + const [sessions, metas, deviceName] = await Promise.all([ + deps.listLocalSessions(), + deps.listLocalMetas(), + deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), + ]) + const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + const snapshot = createSyncSnapshot({ + sessions, + metas, + deviceName, + exportedAt: lastSyncedAt, + }) + const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PUT', + headers: { + ...authHeaders(settings), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(envelope), + }) + assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) + await deps.updateLastSyncedAt(lastSyncedAt) + return { + uploaded: snapshot.sessions.length, + lastSyncedAt, + } +} + +export async function downloadAndMergeWebDAVSnapshot( + settings: Settings, + deps: WebDAVSyncDeps +): Promise { + const webdav = getWebDAVSettings(settings) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'GET', + headers: authHeaders(settings), + }) + + if (response.status === 404) { + return { + imported: 0, + conflicts: 0, + saved: 0, + remoteMissing: true, + } + } + assertSuccess(response, 'Download WebDAV sync snapshot', [200]) + + const envelope = JSON.parse(response.body) as SyncCryptoEnvelope + const remote = parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) + const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()]) + const result = mergeRemoteSnapshot({ + localSessions, + localMetas, + remote, + now: (deps.now ?? Date.now)(), + createId: deps.createId, + }) + + const savedSessionIds: string[] = [] + try { + for (const session of result.sessionsToSave) { + await deps.saveSession(session) + savedSessionIds.push(session.id) + } + if (result.metasToSave.length > 0) { + await deps.saveMetas(result.metasToSave) + } + } catch (error) { + if (deps.deleteSession) { + await Promise.allSettled(savedSessionIds.map((id) => deps.deleteSession?.(id))) + } + throw error + } + + const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + await deps.updateLastSyncedAt(lastSyncedAt) + + return { + imported: result.imported, + conflicts: result.conflicts, + saved: result.sessionsToSave.length, + lastSyncedAt, + } +} diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts new file mode 100644 index 0000000000..cf278fb47e --- /dev/null +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -0,0 +1,160 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import type { SyncSnapshot } from './types' + +function session(id: string, name: string, text: string, type: Session['type'] = 'chat'): Session { + return { + id, + type, + name, + messages: [ + { + id: `${id}-m1`, + role: 'user', + contentParts: [{ type: 'text', text }], + }, + ], + } +} + +function legacySession(id: string, name: string, text: string): Session { + const value = session(id, name, text) + delete value.type + return value +} + +function meta( + id: string, + name: string, + sortOrder = 1, + type: SessionMetaRecord['type'] = 'chat' +): SessionMetaRecord { + return { + id, + name, + type, + sortOrder, + createdAt: sortOrder, + } +} + +function legacyMeta(id: string, name: string, sortOrder = 1): SessionMetaRecord { + const value = meta(id, name, sortOrder) + delete value.type + return value +} + +describe('sync snapshot merge', () => { + it('creates snapshots from only chat and legacy sessions with matching chat metas', () => { + const snapshot = createSyncSnapshot({ + sessions: [ + session('chat-1', 'Chat', 'hello'), + legacySession('legacy-1', 'Legacy', 'hello'), + session('picture-1', 'Picture', 'image prompt', 'picture'), + session('guide-1', 'Guide', 'setup', 'guide'), + ], + metas: [ + meta('chat-1', 'Chat'), + legacyMeta('legacy-1', 'Legacy'), + meta('picture-1', 'Picture', 1, 'picture'), + meta('guide-1', 'Guide', 1, 'guide'), + meta('orphan-1', 'Orphan'), + ], + deviceName: 'Mac', + exportedAt: '2026-06-21T00:00:00.000Z', + }) + + expect(snapshot.sessions.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + expect(snapshot.metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + }) + + it('imports missing remote sessions and metadata', () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Mac', + sessions: [session('remote-1', 'Remote', 'hello')], + metas: [meta('remote-1', 'Remote')], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave.map((s) => s.id)).toEqual(['remote-1']) + expect(result.metasToSave.map((m) => m.id)).toEqual(['remote-1']) + expect(result.imported).toBe(1) + expect(result.conflicts).toBe(0) + }) + + it('ignores non-chat remote sessions and repairs non-chat metas for imported chat sessions', () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + session('chat-1', 'Chat', 'hello'), + legacySession('legacy-1', 'Legacy', 'hello'), + session('picture-1', 'Picture', 'image prompt', 'picture'), + session('guide-1', 'Guide', 'setup', 'guide'), + ], + metas: [ + meta('chat-1', 'Wrong type meta', 5, 'picture'), + legacyMeta('legacy-1', 'Legacy', 4), + meta('picture-1', 'Picture', 3, 'picture'), + meta('guide-1', 'Guide', 2, 'guide'), + ], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + expect(result.metasToSave.map((item) => [item.id, item.type])).toEqual([ + ['chat-1', 'chat'], + ['legacy-1', undefined], + ]) + }) + + it('preserves local data by importing changed remote sessions as synced copies', () => { + const local = session('same-id', 'Project', 'local text') + const remoteSession = session('same-id', 'Project', 'remote text') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [meta('same-id', 'Project')], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote, + now: 2000, + createId: () => 'copy-id', + }) + + expect(result.sessionsToSave).toHaveLength(1) + expect(result.sessionsToSave[0].id).toBe('copy-id') + expect(result.sessionsToSave[0].name).toBe('Project (Synced copy)') + expect(result.metasToSave[0]).toMatchObject({ + id: 'copy-id', + name: 'Project (Synced copy)', + sortOrder: 2000, + createdAt: 2000, + }) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(1) + }) +}) diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts new file mode 100644 index 0000000000..4d37a507ae --- /dev/null +++ b/src/renderer/packages/sync/snapshot.ts @@ -0,0 +1,139 @@ +import { isChatSession, type Session, type SessionMetaRecord } from '@shared/types' +import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' + +function isChatSessionMetaLike(item: Pick): boolean { + return item.type === 'chat' || !item.type +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function sessionsEqual(left: Session, right: Session): boolean { + return stableStringify(left) === stableStringify(right) +} + +function copyName(name: string): string { + return `${name} (Synced copy)` +} + +function metaForSession( + session: Session, + remoteMeta: SessionMetaRecord | undefined, + now: number +): SessionMetaRecord { + return { + id: session.id, + name: session.name, + type: session.type, + starred: session.starred ?? remoteMeta?.starred, + hidden: session.hidden ?? remoteMeta?.hidden, + assistantAvatarKey: session.assistantAvatarKey ?? remoteMeta?.assistantAvatarKey, + picUrl: session.picUrl ?? remoteMeta?.picUrl, + backgroundImage: session.backgroundImage ?? remoteMeta?.backgroundImage, + sortOrder: remoteMeta?.sortOrder ?? now, + createdAt: remoteMeta?.createdAt ?? now, + } +} + +function metaForCopiedSession( + session: Session, + remoteMeta: SessionMetaRecord | undefined, + now: number +): SessionMetaRecord { + return { + id: session.id, + name: session.name, + type: session.type, + starred: session.starred ?? remoteMeta?.starred, + hidden: session.hidden ?? remoteMeta?.hidden, + assistantAvatarKey: session.assistantAvatarKey ?? remoteMeta?.assistantAvatarKey, + picUrl: session.picUrl ?? remoteMeta?.picUrl, + backgroundImage: session.backgroundImage ?? remoteMeta?.backgroundImage, + sortOrder: now, + createdAt: now, + } +} + +export function createSyncSnapshot(input: { + sessions: Session[] + metas: SessionMetaRecord[] + deviceName: string + exportedAt?: string +}): SyncSnapshot { + const sessions = input.sessions.filter(isChatSession) + const sessionIds = new Set(sessions.map((session) => session.id)) + + return { + version: 1, + exportedAt: input.exportedAt ?? new Date().toISOString(), + deviceName: input.deviceName, + sessions, + metas: input.metas.filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)), + } +} + +export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { + const localSessionById = new Map(input.localSessions.filter(isChatSession).map((session) => [session.id, session])) + const localMetaById = new Map(input.localMetas.filter(isChatSessionMetaLike).map((meta) => [meta.id, meta])) + const remoteMetaById = new Map(input.remote.metas.map((meta) => [meta.id, meta])) + const seenRemoteSessionIds = new Set() + + const sessionsToSave: Session[] = [] + const metasToSave: SessionMetaRecord[] = [] + let imported = 0 + let conflicts = 0 + + for (const remoteSession of input.remote.sessions) { + if (!isChatSession(remoteSession) || seenRemoteSessionIds.has(remoteSession.id)) { + continue + } + seenRemoteSessionIds.add(remoteSession.id) + + const localSession = localSessionById.get(remoteSession.id) + const remoteMeta = remoteMetaById.get(remoteSession.id) + + if (!localSession) { + sessionsToSave.push(remoteSession) + metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + imported += 1 + continue + } + + if (sessionsEqual(localSession, remoteSession)) { + if (!localMetaById.has(remoteSession.id) && remoteMeta) { + metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + } + continue + } + + const copiedName = copyName(remoteSession.name) + const copiedSession: Session = { + ...remoteSession, + id: input.createId(), + name: copiedName, + } + sessionsToSave.push(copiedSession) + metasToSave.push({ + ...metaForCopiedSession(copiedSession, remoteMeta, input.now), + name: copiedName, + }) + conflicts += 1 + } + + return { + sessionsToSave, + metasToSave, + imported, + conflicts, + } +} diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts new file mode 100644 index 0000000000..8ac4098a0d --- /dev/null +++ b/src/renderer/packages/sync/types.ts @@ -0,0 +1,37 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +export type { WebDAVMethod, WebDAVRequest, WebDAVResponse } from '@shared/sync-webdav' + +export type SyncProvider = 'webdav' + +export type SyncCryptoEnvelope = { + version: 1 + kdf: 'PBKDF2-SHA256' + cipher: 'AES-GCM' + iterations: number + salt: string + iv: string + ciphertext: string +} + +export type SyncSnapshot = { + version: 1 + exportedAt: string + deviceName: string + sessions: Session[] + metas: SessionMetaRecord[] +} + +export type MergeRemoteSnapshotInput = { + localSessions: Session[] + localMetas: SessionMetaRecord[] + remote: SyncSnapshot + now: number + createId: () => string +} + +export type MergeRemoteSnapshotResult = { + sessionsToSave: Session[] + metasToSave: SessionMetaRecord[] + imported: number + conflicts: number +} diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts new file mode 100644 index 0000000000..141c842d74 --- /dev/null +++ b/src/renderer/packages/sync/webdav.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildBasicAuthHeader, + executeWebDAVRequest, + joinWebDAVUrl, + requestWebDAV, + validateWebDAVRequestTarget, +} from './webdav' + +describe('WebDAV helpers', () => { + it('joins a base collection URL and sync-relative path without duplicate slashes', () => { + expect(joinWebDAVUrl('https://dav.example.com/remote.php/dav/files/me/', '/ChatboxSync/v1/snapshot.json.enc')).toBe( + 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc' + ) + }) + + it('builds a basic auth header from username and password', () => { + expect(buildBasicAuthHeader('alice', 'app-password')).toBe(`Basic ${btoa('alice:app-password')}`) + }) + + it('routes requests through the platform WebDAV request method', async () => { + const webdavRequest = vi.fn(async () => ({ + status: 200, + headers: { etag: '"abc"' }, + body: 'ok', + })) + + const result = await requestWebDAV( + { + webdavRequest, + }, + 'https://dav.example.com/', + { + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + } + ) + + expect(webdavRequest).toHaveBeenCalledWith({ + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + }, 'https://dav.example.com/') + expect(result.body).toBe('ok') + }) + + it('rejects WebDAV requests outside the fixed sync paths and methods', () => { + const baseUrl = 'https://dav.example.com/remote.php/dav/files/me/' + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + }) + ).not.toThrow() + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/other.json', + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'DELETE', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc?target=other', + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { 'X-Forwarded-Host': 'internal' }, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + body: 'unexpected', + }) + ).toThrow(/not allowed/i) + }) + + it('rejects malformed runtime request payloads before transport', () => { + const baseUrl = 'https://dav.example.com/remote.php/dav/files/me/' + const url = 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc' + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: new URL(url) as unknown as string, + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url, + method: 'GET', + headers: { Authorization: 123 as unknown as string }, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url, + method: 'PUT', + body: { payload: true } as unknown as string, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(`${baseUrl}?token=secret`, { + url, + method: 'GET', + }) + ).toThrow(/not allowed/i) + }) + + it('executes WebDAV requests with redirect rejection through the provided transport', async () => { + const fetchImpl = vi.fn(async () => new Response('ok', { status: 200, headers: { ETag: '"abc"' } })) + + const result = await executeWebDAVRequest( + 'https://dav.example.com/remote.php/dav/files/me/', + { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { Authorization: 'Basic abc' }, + }, + fetchImpl + ) + + expect(fetchImpl).toHaveBeenCalledWith( + new URL('https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc'), + { + method: 'GET', + headers: { Authorization: 'Basic abc' }, + body: undefined, + redirect: 'error', + } + ) + expect(result.status).toBe(200) + expect(result.headers.etag).toBe('"abc"') + expect(result.body).toBe('ok') + }) + + it('rejects WebDAV redirects even if the transport returns a redirect response', async () => { + const fetchImpl = vi.fn( + async () => + new Response('', { + status: 302, + headers: { Location: 'https://internal.example.test/' }, + }) + ) + + await expect( + executeWebDAVRequest( + 'https://dav.example.com/remote.php/dav/files/me/', + { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { Authorization: 'Basic abc' }, + }, + fetchImpl + ) + ).rejects.toThrow(/redirect/i) + }) +}) diff --git a/src/renderer/packages/sync/webdav.ts b/src/renderer/packages/sync/webdav.ts new file mode 100644 index 0000000000..580e316f6d --- /dev/null +++ b/src/renderer/packages/sync/webdav.ts @@ -0,0 +1,30 @@ +import { + executeWebDAVRequest, + joinWebDAVUrl, + SYNC_COLLECTION_PATH, + SYNC_SNAPSHOT_PATH, + validateWebDAVRequestTarget, +} from '@shared/sync-webdav' +import type { WebDAVRequest, WebDAVResponse } from './types' + +export { executeWebDAVRequest, joinWebDAVUrl, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH, validateWebDAVRequestTarget } + +type WebDAVCapablePlatform = { + webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise +} + +export function buildBasicAuthHeader(username: string, password: string): string { + return `Basic ${btoa(`${username}:${password}`)}` +} + +export function requestWebDAV( + platform: WebDAVCapablePlatform, + baseUrl: string, + request: WebDAVRequest +): Promise { + validateWebDAVRequestTarget(baseUrl, request) + if (!platform.webdavRequest) { + throw new Error('WebDAV sync is not supported on this platform') + } + return platform.webdavRequest(request, baseUrl) +} diff --git a/src/renderer/platform/desktop_platform.ts b/src/renderer/platform/desktop_platform.ts index 96b51c3641..2e5f9882c7 100644 --- a/src/renderer/platform/desktop_platform.ts +++ b/src/renderer/platform/desktop_platform.ts @@ -12,6 +12,7 @@ import { IndexedDBSessionMetaStorage, type SessionMetaStorage } from '@/storage/ import { IndexedDBTaskSessionStorage, type TaskSessionStorage } from '@/storage/TaskSessionStorage' import { rememberFileNativePath } from '@/utils/file-native-path' import { getOS } from '../packages/navigator' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { Platform, PlatformType } from './interfaces' import DesktopKnowledgeBaseController from './knowledge-base/desktop-controller' import DesktopSessionAttachmentRagController from './session-attachment-rag/desktop-controller' @@ -237,6 +238,10 @@ export default class DesktopPlatform implements Platform { return this.ipc.invoke('ensureAutoLaunch', enable) } + public async webdavRequest(request: WebDAVRequest, _baseUrl: string): Promise { + return this.ipc.invoke('webdav:request', request) + } + async parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> { let result: { text: string; isSupported: boolean } const filePath = this.getLocalFilePath(file) diff --git a/src/renderer/platform/interfaces.ts b/src/renderer/platform/interfaces.ts index 7df39290e3..fd01878b31 100644 --- a/src/renderer/platform/interfaces.ts +++ b/src/renderer/platform/interfaces.ts @@ -3,6 +3,7 @@ import type { Config, Language, Settings, ShortcutSetting } from '@shared/types' import type { ImageGenerationStorage } from '@/storage/ImageGenerationStorage' import type { SessionMetaStorage } from '@/storage/SessionMetaStorage' import type { TaskSessionStorage } from '@/storage/TaskSessionStorage' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { KnowledgeBaseController } from './knowledge-base/interface' import type { SessionAttachmentRagController } from './session-attachment-rag/interface' @@ -79,6 +80,9 @@ export interface Platform extends Storage { ensureAutoLaunch(enable: boolean): Promise + // WebDAV sync transport (Desktop/Mobile only) + webdavRequest?(request: WebDAVRequest, baseUrl: string): Promise + parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> getLocalFilePath(file: File): string readLocalFileContent?(filePath: string): Promise diff --git a/src/renderer/platform/mobile_platform.ts b/src/renderer/platform/mobile_platform.ts index 58fb209156..9f393728a5 100644 --- a/src/renderer/platform/mobile_platform.ts +++ b/src/renderer/platform/mobile_platform.ts @@ -1,7 +1,9 @@ import { App } from '@capacitor/app' import { Browser } from '@capacitor/browser' +import { CapacitorHttp } from '@capacitor/core' import { Device } from '@capacitor/device' import * as defaults from '@shared/defaults' +import { validateWebDAVRequestTarget } from '@shared/sync-webdav' import type { Config, Settings, ShortcutSetting } from '@shared/types' import localforage from 'localforage' import { v4 as uuidv4 } from 'uuid' @@ -13,6 +15,7 @@ import { SQLiteSessionMetaStorage } from '@/storage/SQLiteSessionMetaStorage' import { IndexedDBTaskSessionStorage, type TaskSessionStorage } from '@/storage/TaskSessionStorage' import { CHATBOX_BUILD_PLATFORM } from '@/variables' import { getBrowser, getOS } from '../packages/navigator' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { Platform, PlatformType } from './interfaces' import type { KnowledgeBaseController } from './knowledge-base/interface' import MobileExporter from './mobile_exporter' @@ -243,6 +246,29 @@ export default class MobilePlatform extends MobileSQLiteStorage implements Platf return } + public async webdavRequest(request: WebDAVRequest, baseUrl: string): Promise { + validateWebDAVRequestTarget(baseUrl, request) + const response = await CapacitorHttp.request({ + url: request.url, + method: request.method, + headers: request.headers, + data: request.body, + disableRedirects: true, + webFetchExtra: { + redirect: 'error', + }, + responseType: 'text', + }) + if (response.status >= 300 && response.status <= 399) { + throw new Error('WebDAV redirects are not allowed') + } + return { + status: response.status, + headers: response.headers ?? {}, + body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data ?? ''), + } + } + async parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> { const result = await parseTextFileLocally(file) if (!result.isSupported) { diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 74d844630c..276b86aa92 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -5,8 +5,8 @@ import { Divider, FileButton, Flex, + PasswordInput, Radio, - Select, Stack, Switch, Text, @@ -18,17 +18,21 @@ import { formatFileSize } from '@shared/utils' import { IconInfoCircle } from '@tabler/icons-react' import { createFileRoute } from '@tanstack/react-router' import dayjs from 'dayjs' -import { mapValues, uniqBy } from 'lodash' +import { mapValues } from 'lodash' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { AdaptiveSelect } from '@/components/AdaptiveSelect' import LazySlider from '@/components/common/LazySlider' import { languageNameMap, languages } from '@/i18n/locales' +import { createDefaultWebDAVSyncDeps } from '@/packages/sync/local' +import { downloadAndMergeWebDAVSnapshot, testWebDAVConnection, uploadWebDAVSnapshot } from '@/packages/sync/service' +import { toastError } from '@/packages/toast' import platform from '@/platform' import storage, { StorageKey } from '@/storage' import { getMetaStorage, recoverSessionList } from '@/stores/chatStore' import { migrateOnData } from '@/stores/migration' -import { useSettingsStore } from '@/stores/settingsStore' +import { settingsStore, useSettingsStore } from '@/stores/settingsStore' export const Route = createFileRoute('/settings/general')({ component: RouteComponent, @@ -156,6 +160,14 @@ export function RouteComponent() { {/* Data Recovery */} + {platform.type !== 'web' && ( + <> + + + + + )} + {/* import and export data */} @@ -226,6 +238,149 @@ export function RouteComponent() { ) } +const WebDAVSyncSection = () => { + const { t } = useTranslation() + const { setSettings, sync } = useSettingsStore((state) => ({ + setSettings: state.setSettings, + sync: state.sync, + })) + const [runningAction, setRunningAction] = useState<'test' | 'upload' | 'download' | null>(null) + + const updateWebDAVSettings = (patch: Partial) => { + setSettings((settings) => { + settings.sync.webdav = { + ...settings.sync.webdav, + ...patch, + } + }) + } + + const runSyncAction = async (action: 'test' | 'upload' | 'download', task: () => Promise) => { + if (runningAction) return + setRunningAction(action) + try { + const message = await task() + if (message) { + toast.success(message) + } + } catch (error) { + toastError(error instanceof Error ? error.message : String(error)) + } finally { + setRunningAction(null) + } + } + + const currentSettings = () => settingsStore.getState().getSettings() + + return ( + + + {t('WebDAV Sync')} + + {t( + 'Sync chat history through your own WebDAV storage. API keys, licenses, and provider credentials are not synced.' + )} + + + + + setSettings((settings) => { + settings.sync.enabled = event.currentTarget.checked + }) + } + /> + + updateWebDAVSettings({ url: event.currentTarget.value })} + /> + + updateWebDAVSettings({ username: event.currentTarget.value })} + /> + + updateWebDAVSettings({ password: event.currentTarget.value })} + /> + + updateWebDAVSettings({ syncPassword: event.currentTarget.value })} + /> + + {sync.lastSyncedAt && ( + + {t('Last synced at {{time}}', { time: dayjs(sync.lastSyncedAt).format('YYYY-MM-DD HH:mm') })} + + )} + + + + + + + + ) +} + const DataRecoverySection = () => { const { t } = useTranslation() const [isRecovering, setIsRecovering] = useState(false) diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index 69ffa004e7..d890d967b8 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -151,6 +151,17 @@ export function settings(): Settings { enabledSkillNames: [], translationEnabled: true, }, + sync: { + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }, } } diff --git a/src/shared/sync-settings.test.ts b/src/shared/sync-settings.test.ts new file mode 100644 index 0000000000..b51fd42f82 --- /dev/null +++ b/src/shared/sync-settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import * as defaults from './defaults' +import { SettingsSchema } from './types' + +describe('sync settings defaults', () => { + it('defaults WebDAV sync to disabled and keeps credentials empty', () => { + const settings = SettingsSchema.parse(defaults.settings()) + + expect(settings.sync).toEqual({ + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }) + }) +}) diff --git a/src/shared/sync-webdav.ts b/src/shared/sync-webdav.ts new file mode 100644 index 0000000000..50a80b61d8 --- /dev/null +++ b/src/shared/sync-webdav.ts @@ -0,0 +1,122 @@ +export const SYNC_COLLECTION_PATH = 'ChatboxSync/v1/' +export const SYNC_SNAPSHOT_PATH = `${SYNC_COLLECTION_PATH}snapshot.json.enc` + +export type WebDAVMethod = 'GET' | 'PUT' | 'MKCOL' | 'PROPFIND' | 'DELETE' + +export type WebDAVRequest = { + url: string + method: WebDAVMethod + headers?: Record + body?: string +} + +export type WebDAVResponse = { + status: number + headers: Record + body: string +} + +type WebDAVFetch = ( + input: string | URL, + init: { + method: WebDAVMethod + headers?: Record + body?: string + redirect: 'error' + } +) => Promise + +const ALLOWED_WEBDAV_TARGETS = new Map>([ + ['ChatboxSync/', new Set(['MKCOL'])], + [SYNC_COLLECTION_PATH, new Set(['MKCOL'])], + [SYNC_SNAPSHOT_PATH, new Set(['GET', 'PUT', 'PROPFIND'])], +]) +const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth']) +const REDIRECT_STATUS_MIN = 300 +const REDIRECT_STATUS_MAX = 399 + +function rejectWebDAVRequest(): never { + throw new Error('WebDAV request target is not allowed') +} + +export function joinWebDAVUrl(base: string, path: string): string { + return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}` +} + +function normalizeRelativePath(pathname: string): string { + return pathname.replace(/^\/+/, '') +} + +export function validateWebDAVRequestTarget(baseUrl: string, request: WebDAVRequest): void { + if (typeof baseUrl !== 'string' || typeof request !== 'object' || request === null || Array.isArray(request)) { + rejectWebDAVRequest() + } + if (typeof request.url !== 'string' || typeof request.method !== 'string') { + rejectWebDAVRequest() + } + if (request.headers !== undefined && (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers))) { + rejectWebDAVRequest() + } + if (request.body !== undefined && typeof request.body !== 'string') { + rejectWebDAVRequest() + } + + const base = new URL(baseUrl) + const url = new URL(request.url) + if (base.protocol !== 'https:' || url.protocol !== 'https:') { + throw new Error('WebDAV URL must use HTTPS') + } + if (base.search || base.hash) { + rejectWebDAVRequest() + } + const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/` + if (url.origin !== base.origin || !url.pathname.startsWith(basePath)) { + rejectWebDAVRequest() + } + if (url.search || url.hash) { + rejectWebDAVRequest() + } + + const relativePath = normalizeRelativePath(url.pathname.slice(basePath.length)) + const allowedMethods = ALLOWED_WEBDAV_TARGETS.get(relativePath) + if (!allowedMethods?.has(request.method)) { + rejectWebDAVRequest() + } + for (const [headerName, headerValue] of Object.entries(request.headers ?? {})) { + if (typeof headerValue !== 'string') { + rejectWebDAVRequest() + } + if (!ALLOWED_WEBDAV_HEADERS.has(headerName.toLowerCase())) { + rejectWebDAVRequest() + } + } + if (request.body !== undefined && request.method !== 'PUT') { + rejectWebDAVRequest() + } +} + +export async function executeWebDAVRequest( + baseUrl: string, + request: WebDAVRequest, + fetchImpl: WebDAVFetch +): Promise { + validateWebDAVRequestTarget(baseUrl, request) + const response = await fetchImpl(new URL(request.url), { + method: request.method, + headers: request.headers, + body: request.body, + redirect: 'error', + }) + if (response.status >= REDIRECT_STATUS_MIN && response.status <= REDIRECT_STATUS_MAX) { + throw new Error('WebDAV redirects are not allowed') + } + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + return { + status: response.status, + headers, + body: await response.text(), + } +} diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 5b809c9973..241dbbffad 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -282,6 +282,25 @@ const MCPSettingsSchema = z.object({ enabledBuiltinServers: z.array(z.string()), }) +export const SyncSettingsSchema = z.object({ + enabled: z.boolean().catch(false), + provider: z.literal('webdav').catch('webdav'), + webdav: z + .object({ + url: z.string().catch(''), + username: z.string().catch(''), + password: z.string().catch(''), + syncPassword: z.string().catch(''), + }) + .catch({ + url: '', + username: '', + password: '', + syncPassword: '', + }), + lastSyncedAt: z.string().optional().catch(undefined), +}) + export enum Theme { Dark, Light, @@ -414,6 +433,17 @@ export const SettingsSchema = GlobalSessionSettingsSchema.extend({ enabledSkillNames: [], translationEnabled: true, }), + sync: SyncSettingsSchema.catch({ + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }), }) // TODO: provider的 base info 和 settings混在一起了,可以考虑像 session settings 和 global settings一样拆开 @@ -441,6 +471,7 @@ export type ExtensionSettings = z.infer export type MCPTransportConfig = z.infer export type MCPServerConfig = z.infer export type MCPSettings = z.infer +export type SyncSettings = z.infer // Re-export SkillSettings for convenience export type { SkillSettings } from './skills' From 0279b3baa6b1b0c9afa1422f15fbde14f1bb4d74 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 09:47:12 +0800 Subject: [PATCH 2/6] Address WebDAV sync review feedback --- src/renderer/packages/sync/service.test.ts | 49 ++++++- src/renderer/packages/sync/service.ts | 50 +++++-- src/renderer/packages/sync/snapshot.test.ts | 139 +++++++++++++++++++- src/renderer/packages/sync/snapshot.ts | 134 +++++++++++++++++-- src/renderer/packages/sync/webdav.test.ts | 19 ++- src/renderer/packages/sync/webdav.ts | 7 +- 6 files changed, 360 insertions(+), 38 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 9e45e6eac3..43ab56ceff 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -50,7 +50,11 @@ describe('WebDAV sync service', () => { getDeviceName: vi.fn(async () => 'Mac'), webdavRequest: vi.fn((request: WebDAVRequest) => { requests.push(request) - return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + return Promise.resolve({ + status: request.method === 'PUT' ? 201 : request.method === 'GET' ? 404 : 405, + headers: {}, + body: '', + }) }), }, listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), @@ -77,6 +81,49 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:01.000Z') }) + it('merges the existing remote snapshot before uploading local sessions', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + + expect(result.uploaded).toBe(2) + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.saveMetas).not.toHaveBeenCalled() + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7ce7132ff4..7cc40626f7 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -121,6 +121,23 @@ async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatfor } } +async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { + const webdav = getWebDAVSettings(settings) + const response = await requestWebDAV(platform, webdav.url, { + url: snapshotUrl(settings), + method: 'GET', + headers: authHeaders(settings), + }) + + if (response.status === 404) { + return undefined + } + assertSuccess(response, 'Download WebDAV sync snapshot', [200]) + + const envelope = JSON.parse(response.body) as SyncCryptoEnvelope + return parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) +} + export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { const webdav = getWebDAVSettings(settings) await ensureWebDAVCollections(settings, deps.platform) @@ -148,12 +165,30 @@ export async function uploadWebDAVSnapshot( deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() - const snapshot = createSyncSnapshot({ + const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, }) + const remoteSnapshot = await downloadWebDAVSnapshot(settings, deps.platform) + const mergeResult = remoteSnapshot + ? mergeRemoteSnapshot({ + localSessions: localSnapshot.sessions, + localMetas: localSnapshot.metas, + remote: remoteSnapshot, + now: (deps.now ?? Date.now)(), + createId: deps.createId, + }) + : undefined + const snapshot = mergeResult + ? createSyncSnapshot({ + sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], + metas: [...localSnapshot.metas, ...mergeResult.metasToSave], + deviceName, + exportedAt: lastSyncedAt, + }) + : localSnapshot const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) const response = await requestWebDAV(deps.platform, webdav.url, { url: snapshotUrl(settings), @@ -176,14 +211,8 @@ export async function downloadAndMergeWebDAVSnapshot( settings: Settings, deps: WebDAVSyncDeps ): Promise { - const webdav = getWebDAVSettings(settings) - const response = await requestWebDAV(deps.platform, webdav.url, { - url: snapshotUrl(settings), - method: 'GET', - headers: authHeaders(settings), - }) - - if (response.status === 404) { + const remote = await downloadWebDAVSnapshot(settings, deps.platform) + if (!remote) { return { imported: 0, conflicts: 0, @@ -191,10 +220,7 @@ export async function downloadAndMergeWebDAVSnapshot( remoteMissing: true, } } - assertSuccess(response, 'Download WebDAV sync snapshot', [200]) - const envelope = JSON.parse(response.body) as SyncCryptoEnvelope - const remote = parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()]) const result = mergeRemoteSnapshot({ localSessions, diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index cf278fb47e..9de84e7e88 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -24,12 +24,7 @@ function legacySession(id: string, name: string, text: string): Session { return value } -function meta( - id: string, - name: string, - sortOrder = 1, - type: SessionMetaRecord['type'] = 'chat' -): SessionMetaRecord { +function meta(id: string, name: string, sortOrder = 1, type: SessionMetaRecord['type'] = 'chat'): SessionMetaRecord { return { id, name, @@ -69,6 +64,100 @@ describe('sync snapshot merge', () => { expect(snapshot.metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) }) + it('strips local-only blob references when creating snapshots', () => { + const local = session('chat-1', 'Chat', 'hello') + local.assistantAvatarKey = 'avatar-key' + local.backgroundImage = { type: 'storage-key', storageKey: 'background-key' } + local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) + local.messages[0].files = [ + { + id: 'file-1', + name: 'doc.txt', + fileType: 'text/plain', + storageKey: 'file-key', + localPath: '/tmp/doc.txt', + ragMode: 'session-retrieval', + sessionAttachmentId: 12, + sessionAttachmentAvailability: 'allowed', + tokenCountMap: { default: 10 }, + lineCount: 5, + byteLength: 123, + }, + ] + local.messages[0].links = [ + { + id: 'link-1', + title: 'Example', + url: 'https://example.com', + storageKey: 'link-key', + tokenCountMap: { default: 20 }, + lineCount: 10, + byteLength: 456, + }, + ] + ;(local.messages[0] as unknown as { pictures: Array<{ storageKey: string }> }).pictures = [ + { storageKey: 'legacy-picture-key' }, + ] + local.threads = [ + { + id: 'thread-1', + name: 'Thread', + createdAt: 1, + messages: [ + { + id: 'thread-message-1', + role: 'user', + contentParts: [{ type: 'image', storageKey: 'thread-image-key' }], + }, + ], + }, + ] + local.messageForksHash = { + fork: { + position: 0, + createdAt: 1, + lists: [ + { + id: 'fork-list', + messages: [ + { + id: 'fork-message-1', + role: 'user', + contentParts: [{ type: 'image', storageKey: 'fork-image-key' }], + }, + ], + }, + ], + }, + } + + const snapshot = createSyncSnapshot({ + sessions: [local], + metas: [ + { + ...meta('chat-1', 'Chat'), + assistantAvatarKey: 'avatar-key', + backgroundImage: { type: 'storage-key', storageKey: 'background-key' }, + }, + ], + deviceName: 'Mac', + exportedAt: '2026-06-21T00:00:00.000Z', + }) + const synced = snapshot.sessions[0] + const message = synced.messages[0] + + expect(synced.assistantAvatarKey).toBeUndefined() + expect(synced.backgroundImage).toBeUndefined() + expect(message.contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(message.files).toEqual([{ id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }]) + expect(message.links).toEqual([{ id: 'link-1', title: 'Example', url: 'https://example.com' }]) + expect(message).not.toHaveProperty('pictures') + expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) + expect(synced.messageForksHash?.fork.lists[0].messages[0].contentParts).toEqual([]) + expect(snapshot.metas[0].assistantAvatarKey).toBeUndefined() + expect(snapshot.metas[0].backgroundImage).toBeUndefined() + }) + it('imports missing remote sessions and metadata', () => { const remote: SyncSnapshot = { version: 1, @@ -92,6 +181,44 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(0) }) + it('strips local-only blob references when importing remote sessions', () => { + const remoteSession = session('remote-1', 'Remote', 'hello') + remoteSession.assistantAvatarKey = 'avatar-key' + remoteSession.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) + remoteSession.messages[0].files = [ + { id: 'file-1', name: 'doc.txt', fileType: 'text/plain', storageKey: 'file-key', localPath: '/tmp/doc.txt' }, + ] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [ + { + ...meta('remote-1', 'Remote'), + assistantAvatarKey: 'avatar-key', + backgroundImage: { type: 'storage-key', storageKey: 'background-key' }, + }, + ], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave[0].assistantAvatarKey).toBeUndefined() + expect(result.sessionsToSave[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.sessionsToSave[0].messages[0].files).toEqual([ + { id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }, + ]) + expect(result.metasToSave[0].assistantAvatarKey).toBeUndefined() + expect(result.metasToSave[0].backgroundImage).toBeUndefined() + }) + it('ignores non-chat remote sessions and repairs non-chat metas for imported chat sessions', () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 4d37a507ae..95f1fac293 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -1,4 +1,11 @@ -import { isChatSession, type Session, type SessionMetaRecord } from '@shared/types' +import { + isChatSession, + type Message, + type MessageFile, + type MessageLink, + type Session, + type SessionMetaRecord, +} from '@shared/types' import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' function isChatSessionMetaLike(item: Pick): boolean { @@ -22,15 +29,105 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } +function stripLocalFileReferences(file: MessageFile): MessageFile { + const { + storageKey: _storageKey, + localPath: _localPath, + ragMode: _ragMode, + sessionAttachmentId: _sessionAttachmentId, + sessionAttachmentAvailability: _sessionAttachmentAvailability, + sessionAttachmentIndexStatus: _sessionAttachmentIndexStatus, + sessionAttachmentBlockedReason: _sessionAttachmentBlockedReason, + sessionAttachmentWarningReason: _sessionAttachmentWarningReason, + sessionAttachmentStatus: _sessionAttachmentStatus, + sessionAttachmentChunkCount: _sessionAttachmentChunkCount, + sessionAttachmentIndexingStage: _sessionAttachmentIndexingStage, + sessionAttachmentTotalChunks: _sessionAttachmentTotalChunks, + sessionAttachmentEmbeddedChunks: _sessionAttachmentEmbeddedChunks, + tokenCountMap: _tokenCountMap, + tokenCalculatedAt: _tokenCalculatedAt, + lineCount: _lineCount, + byteLength: _byteLength, + ...rest + } = file + return rest +} + +function stripLocalLinkReferences(link: MessageLink): MessageLink { + const { + storageKey: _storageKey, + tokenCountMap: _tokenCountMap, + tokenCalculatedAt: _tokenCalculatedAt, + lineCount: _lineCount, + byteLength: _byteLength, + ...rest + } = link + return rest +} + +function stripLocalMessageReferences(message: Message): Message { + const { pictures: _pictures, ...messageWithoutLegacyPictures } = message as Message & { pictures?: unknown } + const result: Message = { + ...messageWithoutLegacyPictures, + contentParts: message.contentParts.filter((part) => part.type !== 'image'), + } + + if (message.files) { + result.files = message.files.map(stripLocalFileReferences) + } + if (message.links) { + result.links = message.links.map(stripLocalLinkReferences) + } + + return result +} + +function stripLocalSessionReferences(session: Session): Session { + const { + assistantAvatarKey: _assistantAvatarKey, + backgroundImage: _backgroundImage, + ...sessionWithoutLocalImages + } = session + + return { + ...sessionWithoutLocalImages, + backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, + messages: session.messages.map(stripLocalMessageReferences), + threads: session.threads?.map((thread) => ({ + ...thread, + messages: thread.messages.map(stripLocalMessageReferences), + })), + messageForksHash: session.messageForksHash + ? Object.fromEntries( + Object.entries(session.messageForksHash).map(([key, fork]) => [ + key, + { + ...fork, + lists: fork.lists.map((list) => ({ + ...list, + messages: list.messages.map(stripLocalMessageReferences), + })), + }, + ]) + ) + : undefined, + } +} + +function stripLocalMetaReferences(meta: SessionMetaRecord): SessionMetaRecord { + const { assistantAvatarKey: _assistantAvatarKey, backgroundImage: _backgroundImage, ...metaWithoutLocalImages } = meta + + return { + ...metaWithoutLocalImages, + backgroundImage: meta.backgroundImage?.type === 'url' ? meta.backgroundImage : undefined, + } +} + function copyName(name: string): string { return `${name} (Synced copy)` } -function metaForSession( - session: Session, - remoteMeta: SessionMetaRecord | undefined, - now: number -): SessionMetaRecord { +function metaForSession(session: Session, remoteMeta: SessionMetaRecord | undefined, now: number): SessionMetaRecord { return { id: session.id, name: session.name, @@ -70,7 +167,7 @@ export function createSyncSnapshot(input: { deviceName: string exportedAt?: string }): SyncSnapshot { - const sessions = input.sessions.filter(isChatSession) + const sessions = input.sessions.filter(isChatSession).map(stripLocalSessionReferences) const sessionIds = new Set(sessions.map((session) => session.id)) return { @@ -78,14 +175,27 @@ export function createSyncSnapshot(input: { exportedAt: input.exportedAt ?? new Date().toISOString(), deviceName: input.deviceName, sessions, - metas: input.metas.filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)), + metas: input.metas + .filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)) + .map(stripLocalMetaReferences), } } export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { - const localSessionById = new Map(input.localSessions.filter(isChatSession).map((session) => [session.id, session])) - const localMetaById = new Map(input.localMetas.filter(isChatSessionMetaLike).map((meta) => [meta.id, meta])) - const remoteMetaById = new Map(input.remote.metas.map((meta) => [meta.id, meta])) + const localSessionById = new Map( + input.localSessions.filter(isChatSession).map((session) => { + const sanitizedSession = stripLocalSessionReferences(session) + return [sanitizedSession.id, sanitizedSession] + }) + ) + const localMetaById = new Map( + input.localMetas.filter(isChatSessionMetaLike).map((meta) => { + const sanitizedMeta = stripLocalMetaReferences(meta) + return [sanitizedMeta.id, sanitizedMeta] + }) + ) + const remoteMetas = input.remote.metas.map(stripLocalMetaReferences) + const remoteMetaById = new Map(remoteMetas.map((meta) => [meta.id, meta])) const seenRemoteSessionIds = new Set() const sessionsToSave: Session[] = [] @@ -93,7 +203,7 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot let imported = 0 let conflicts = 0 - for (const remoteSession of input.remote.sessions) { + for (const remoteSession of input.remote.sessions.map(stripLocalSessionReferences)) { if (!isChatSession(remoteSession) || seenRemoteSessionIds.has(remoteSession.id)) { continue } diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts index 141c842d74..b81f95d8fe 100644 --- a/src/renderer/packages/sync/webdav.test.ts +++ b/src/renderer/packages/sync/webdav.test.ts @@ -18,6 +18,10 @@ describe('WebDAV helpers', () => { expect(buildBasicAuthHeader('alice', 'app-password')).toBe(`Basic ${btoa('alice:app-password')}`) }) + it('encodes basic auth credentials as UTF-8 before base64 encoding', () => { + expect(buildBasicAuthHeader('用户', '密钥')).toBe(`Basic ${Buffer.from('用户:密钥', 'utf8').toString('base64')}`) + }) + it('routes requests through the platform WebDAV request method', async () => { const webdavRequest = vi.fn(async () => ({ status: 200, @@ -38,12 +42,15 @@ describe('WebDAV helpers', () => { } ) - expect(webdavRequest).toHaveBeenCalledWith({ - url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', - method: 'PUT', - headers: { Authorization: 'Basic abc' }, - body: 'payload', - }, 'https://dav.example.com/') + expect(webdavRequest).toHaveBeenCalledWith( + { + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + }, + 'https://dav.example.com/' + ) expect(result.body).toBe('ok') }) diff --git a/src/renderer/packages/sync/webdav.ts b/src/renderer/packages/sync/webdav.ts index 580e316f6d..8a8ad8e20d 100644 --- a/src/renderer/packages/sync/webdav.ts +++ b/src/renderer/packages/sync/webdav.ts @@ -14,7 +14,12 @@ type WebDAVCapablePlatform = { } export function buildBasicAuthHeader(username: string, password: string): string { - return `Basic ${btoa(`${username}:${password}`)}` + const bytes = new TextEncoder().encode(`${username}:${password}`) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return `Basic ${btoa(binary)}` } export function requestWebDAV( From d581e2b49a123f01e3101dca30e02d872fdbc6e4 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 10:14:41 +0800 Subject: [PATCH 3/6] Strip local file keys from synced attachment ids --- src/renderer/packages/sync/snapshot.test.ts | 25 ++++++++++++++++----- src/renderer/packages/sync/snapshot.ts | 12 ++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index 9de84e7e88..b1de1ce25e 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -71,10 +71,10 @@ describe('sync snapshot merge', () => { local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) local.messages[0].files = [ { - id: 'file-1', + id: 'file:/tmp/doc.txt-123-456', name: 'doc.txt', fileType: 'text/plain', - storageKey: 'file-key', + storageKey: 'file:/tmp/doc.txt-123-456', localPath: '/tmp/doc.txt', ragMode: 'session-retrieval', sessionAttachmentId: 12, @@ -83,6 +83,12 @@ describe('sync snapshot merge', () => { lineCount: 5, byteLength: 123, }, + { + id: 'custom-file-id', + name: 'custom.txt', + fileType: 'text/plain', + storageKey: 'file:/tmp/custom.txt-789-123', + }, ] local.messages[0].links = [ { @@ -149,7 +155,10 @@ describe('sync snapshot merge', () => { expect(synced.assistantAvatarKey).toBeUndefined() expect(synced.backgroundImage).toBeUndefined() expect(message.contentParts).toEqual([{ type: 'text', text: 'hello' }]) - expect(message.files).toEqual([{ id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }]) + expect(message.files).toEqual([ + { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, + { id: 'custom-file-id', name: 'custom.txt', fileType: 'text/plain' }, + ]) expect(message.links).toEqual([{ id: 'link-1', title: 'Example', url: 'https://example.com' }]) expect(message).not.toHaveProperty('pictures') expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) @@ -186,7 +195,13 @@ describe('sync snapshot merge', () => { remoteSession.assistantAvatarKey = 'avatar-key' remoteSession.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) remoteSession.messages[0].files = [ - { id: 'file-1', name: 'doc.txt', fileType: 'text/plain', storageKey: 'file-key', localPath: '/tmp/doc.txt' }, + { + id: 'file:/tmp/doc.txt-123-456', + name: 'doc.txt', + fileType: 'text/plain', + storageKey: 'file:/tmp/doc.txt-123-456', + localPath: '/tmp/doc.txt', + }, ] const remote: SyncSnapshot = { version: 1, @@ -213,7 +228,7 @@ describe('sync snapshot merge', () => { expect(result.sessionsToSave[0].assistantAvatarKey).toBeUndefined() expect(result.sessionsToSave[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) expect(result.sessionsToSave[0].messages[0].files).toEqual([ - { id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }, + { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, ]) expect(result.metasToSave[0].assistantAvatarKey).toBeUndefined() expect(result.metasToSave[0].backgroundImage).toBeUndefined() diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 95f1fac293..f915df974e 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -29,8 +29,13 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } -function stripLocalFileReferences(file: MessageFile): MessageFile { +function shouldNormalizeFileAttachmentId(file: MessageFile): boolean { + return file.id === file.storageKey || file.id === file.localPath || file.id.startsWith('file:') +} + +function stripLocalFileReferences(file: MessageFile, index: number): MessageFile { const { + id, storageKey: _storageKey, localPath: _localPath, ragMode: _ragMode, @@ -50,7 +55,10 @@ function stripLocalFileReferences(file: MessageFile): MessageFile { byteLength: _byteLength, ...rest } = file - return rest + return { + id: shouldNormalizeFileAttachmentId(file) ? `synced-file:${index}:${file.name}` : id, + ...rest, + } } function stripLocalLinkReferences(link: MessageLink): MessageLink { From 49ebca2ebfaa1b2c719549f283ea4d79fe9414c1 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 10:39:56 +0800 Subject: [PATCH 4/6] Redact WebDAV sync secrets from settings export --- src/renderer/packages/settings-export.test.ts | 66 +++++++++++++++++++ src/renderer/packages/settings-export.ts | 38 +++++++++++ .../SettingDialog/AdvancedSettingTab.tsx | 12 ++-- src/renderer/routes/settings/general.tsx | 25 ++----- 4 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 src/renderer/packages/settings-export.test.ts create mode 100644 src/renderer/packages/settings-export.ts diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts new file mode 100644 index 0000000000..ed5464ad9b --- /dev/null +++ b/src/renderer/packages/settings-export.test.ts @@ -0,0 +1,66 @@ +import * as defaults from '@shared/defaults' +import type { Settings } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { sanitizeSettingsForExport } from './settings-export' + +function settingsWithSecrets(): Settings { + return { + ...defaults.settings(), + licenseKey: 'license-secret', + licenseDetail: { plan: 'pro' } as unknown as Settings['licenseDetail'], + licenseInstances: { 'license-secret': 'device-1' }, + providers: { + openai: { + apiKey: 'sk-secret', + accessKey: 'access-secret', + secretKey: 'secret-key', + sessionToken: 'session-token', + apiHost: 'https://api.example.com', + }, + }, + sync: { + enabled: true, + provider: 'webdav', + webdav: { + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: 'dav-secret', + syncPassword: 'sync-secret', + }, + lastSyncedAt: '2026-07-05T00:00:00.000Z', + }, + } +} + +describe('sanitizeSettingsForExport', () => { + it('removes WebDAV and provider secrets when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.licenseKey).toBeUndefined() + expect(sanitized.licenseDetail).toBeUndefined() + expect(sanitized.licenseInstances).toBeUndefined() + expect(sanitized.providers?.openai).toEqual({ + apiHost: 'https://api.example.com', + }) + expect(sanitized.sync.webdav).toEqual({ + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: '', + syncPassword: '', + }) + expect(settings.sync.webdav.password).toBe('dav-secret') + expect(settings.sync.webdav.syncPassword).toBe('sync-secret') + }) + + it('keeps WebDAV and provider secrets when key export is selected', () => { + const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), true) + + expect(sanitized.licenseKey).toBe('license-secret') + expect(sanitized.providers?.openai?.apiKey).toBe('sk-secret') + expect(sanitized.sync.webdav.password).toBe('dav-secret') + expect(sanitized.sync.webdav.syncPassword).toBe('sync-secret') + expect(sanitized.licenseDetail).toBeUndefined() + expect(sanitized.licenseInstances).toBeUndefined() + }) +}) diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts new file mode 100644 index 0000000000..94fc80ab08 --- /dev/null +++ b/src/renderer/packages/settings-export.ts @@ -0,0 +1,38 @@ +import type { ProviderSettings, Settings } from '@shared/types' + +function sanitizeProviderForExport(provider: ProviderSettings): ProviderSettings { + const cleanedProvider = { ...provider } + delete cleanedProvider.apiKey + delete cleanedProvider.accessKey + delete cleanedProvider.secretKey + delete cleanedProvider.sessionToken + return cleanedProvider +} + +export function sanitizeSettingsForExport(settings: Settings, includeSecrets: boolean): Settings { + const cleanedSettings: Settings = { + ...settings, + licenseDetail: undefined, + licenseInstances: undefined, + providers: settings.providers ? { ...settings.providers } : settings.providers, + sync: { + ...settings.sync, + webdav: { + ...settings.sync.webdav, + }, + }, + } + + if (!includeSecrets) { + delete cleanedSettings.licenseKey + if (cleanedSettings.providers) { + cleanedSettings.providers = Object.fromEntries( + Object.entries(cleanedSettings.providers).map(([id, provider]) => [id, sanitizeProviderForExport(provider)]) + ) as Settings['providers'] + } + cleanedSettings.sync.webdav.password = '' + cleanedSettings.sync.webdav.syncPassword = '' + } + + return cleanedSettings +} diff --git a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx index 639bf9898b..223585c2d9 100644 --- a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx +++ b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx @@ -18,6 +18,7 @@ import { Accordion, AccordionDetails, AccordionSummary } from '@/components/Acco import TextFieldReset from '@/components/common/TextFieldReset' import { ShortcutConfig } from '@/components/Shortcut' import { useIsSmallScreen } from '@/hooks/useScreenChange' +import { sanitizeSettingsForExport } from '@/packages/settings-export' import platform from '@/platform' import storage, { StorageKey } from '@/storage' import { migrateOnData } from '@/stores/migration' @@ -163,10 +164,13 @@ function ExportAndImport(props: { onCancel: () => void }) { const onExport = async () => { const data = await storage.getAll() delete data[StorageKey.Configs] // 不导出 uuid - ;(data[StorageKey.Settings] as Settings).licenseDetail = undefined // 不导出license认证数据 - ;(data[StorageKey.Settings] as Settings).licenseInstances = undefined // 不导出license设备数据,导入数据的新设备也应该计入设备数 - if (!exportItems.includes(ExportDataItem.Key)) { - delete (data[StorageKey.Settings] as Settings).licenseKey + if (data[StorageKey.Settings]) { + data[StorageKey.Settings] = sanitizeSettingsForExport( + data[StorageKey.Settings] as Settings, + exportItems.includes(ExportDataItem.Key) + ) + } + if (!exportItems.includes(ExportDataItem.Key) && data[StorageKey.Settings]) { delete (data[StorageKey.Settings] as Settings).providers } if (!exportItems.includes(ExportDataItem.Setting)) { diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 276b86aa92..aafb7d5fa5 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -13,18 +13,18 @@ import { TextInput, Title, } from '@mantine/core' -import { type Language, type ProviderInfo, type Settings, Theme } from '@shared/types' +import { type Language, type Settings, Theme } from '@shared/types' import { formatFileSize } from '@shared/utils' import { IconInfoCircle } from '@tabler/icons-react' import { createFileRoute } from '@tanstack/react-router' import dayjs from 'dayjs' -import { mapValues } from 'lodash' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AdaptiveSelect } from '@/components/AdaptiveSelect' import LazySlider from '@/components/common/LazySlider' import { languageNameMap, languages } from '@/i18n/locales' +import { sanitizeSettingsForExport } from '@/packages/settings-export' import { createDefaultWebDAVSyncDeps } from '@/packages/sync/local' import { downloadAndMergeWebDAVSnapshot, testWebDAVConnection, uploadWebDAVSnapshot } from '@/packages/sync/service' import { toastError } from '@/packages/toast' @@ -524,23 +524,10 @@ const ImportExportDataSection = () => { if (value !== null) { // 对settings进行特殊处理,清理敏感数据 if (key === StorageKey.Settings) { - const cleanedSettings = { ...(value as Settings) } - cleanedSettings.licenseDetail = undefined - cleanedSettings.licenseInstances = undefined - - if (!exportItems.includes(ExportDataItem.Key)) { - delete cleanedSettings.licenseKey - if (cleanedSettings.providers) { - cleanedSettings.providers = mapValues(cleanedSettings.providers, (provider: ProviderInfo) => { - const cleanedProvider = { ...provider } - delete cleanedProvider.apiKey - delete cleanedProvider.accessKey - delete cleanedProvider.secretKey - delete cleanedProvider.sessionToken - return cleanedProvider - }) as unknown as { [key: string]: ProviderInfo } - } - } + const cleanedSettings = sanitizeSettingsForExport( + value as Settings, + exportItems.includes(ExportDataItem.Key) + ) yield ',' yield `"${key}":${JSON.stringify(cleanedSettings)}` From d2a0920aa2982c29add79b1564b3be7f18a695d3 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 11:00:43 +0800 Subject: [PATCH 5/6] Handle metadata-only WebDAV sync updates --- src/renderer/packages/sync/service.test.ts | 50 ++++++++++- src/renderer/packages/sync/service.ts | 1 + src/renderer/packages/sync/snapshot.test.ts | 61 ++++++++++++++ src/renderer/packages/sync/snapshot.ts | 92 ++++++++++++++++----- src/renderer/packages/sync/types.ts | 2 + 5 files changed, 185 insertions(+), 21 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 43ab56ceff..99f5aa753a 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -1,4 +1,4 @@ -import type { Settings } from '@shared/types' +import type { Session, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' @@ -17,7 +17,7 @@ const baseSettings = { }, } as Settings -function session(id: string, name: string, text: string) { +function session(id: string, name: string, text: string): Session { return { id, type: 'chat' as const, @@ -167,6 +167,52 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') }) + it('downloads metadata-only changes without creating synced copies', async () => { + const local = session('same-id', 'Local Name', 'same text') + local.settings = { temperature: undefined } + const remoteSession = { + ...session('same-id', 'Remote Name', 'same text'), + starred: true, + } + const remoteMeta = { + ...meta('same-id', 'Remote Name', 99), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [remoteMeta], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => [local]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name', 1)]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + expect(deps.createId).not.toHaveBeenCalled() + expect(deps.saveSession).toHaveBeenCalledWith(expect.objectContaining({ id: 'same-id', name: 'Remote Name' })) + expect(deps.saveMetas).toHaveBeenCalledWith([expect.objectContaining(remoteMeta)]) + }) + it('rejects plaintext HTTP WebDAV URLs before sending credentials', async () => { const deps = { platform: { diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7cc40626f7..3336f1d7e8 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -228,6 +228,7 @@ export async function downloadAndMergeWebDAVSnapshot( remote, now: (deps.now ?? Date.now)(), createId: deps.createId, + preferRemoteMetadata: true, }) const savedSessionIds: string[] = [] diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index b1de1ce25e..bcc2b0c0a1 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -299,4 +299,65 @@ describe('sync snapshot merge', () => { expect(result.imported).toBe(0) expect(result.conflicts).toBe(1) }) + + it('updates remote metadata for same-content sessions without creating synced copies when requested', () => { + const local = session('same-id', 'Project', 'same text') + const remoteSession = { + ...session('same-id', 'Project Remote', 'same text'), + starred: true, + } + const remoteMeta = { + ...meta('same-id', 'Project Remote', 99), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [remoteMeta], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project', 1)], + remote, + now: 2000, + createId: () => 'copy-id', + preferRemoteMetadata: true, + }) + + expect(result.sessionsToSave).toEqual([expect.objectContaining({ id: 'same-id', name: 'Project Remote' })]) + expect(result.metasToSave).toEqual([remoteMeta]) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + }) + + it('keeps local metadata for same-content sessions during upload merges', () => { + const local = session('same-id', 'Project', 'same text') + const remoteSession = { + ...session('same-id', 'Project Remote', 'same text'), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [{ ...meta('same-id', 'Project Remote', 99), starred: true }], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project', 1)], + remote, + now: 2000, + createId: () => 'copy-id', + }) + + expect(result.sessionsToSave).toEqual([]) + expect(result.metasToSave).toEqual([]) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + }) }) diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index f915df974e..2c269c193a 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -29,6 +29,32 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } +function sessionContentEqual(left: Session, right: Session): boolean { + const { + name: _leftName, + starred: _leftStarred, + hidden: _leftHidden, + assistantAvatarKey: _leftAssistantAvatarKey, + picUrl: _leftPicUrl, + backgroundImage: _leftBackgroundImage, + ...leftContent + } = left + const { + name: _rightName, + starred: _rightStarred, + hidden: _rightHidden, + assistantAvatarKey: _rightAssistantAvatarKey, + picUrl: _rightPicUrl, + backgroundImage: _rightBackgroundImage, + ...rightContent + } = right + return stableStringify(leftContent) === stableStringify(rightContent) +} + +function metasEqual(left: SessionMetaRecord, right: SessionMetaRecord): boolean { + return stableStringify(left) === stableStringify(right) +} + function shouldNormalizeFileAttachmentId(file: MessageFile): boolean { return file.id === file.storageKey || file.id === file.localPath || file.id.startsWith('file:') } @@ -96,18 +122,11 @@ function stripLocalSessionReferences(session: Session): Session { backgroundImage: _backgroundImage, ...sessionWithoutLocalImages } = session - - return { - ...sessionWithoutLocalImages, - backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, - messages: session.messages.map(stripLocalMessageReferences), - threads: session.threads?.map((thread) => ({ - ...thread, - messages: thread.messages.map(stripLocalMessageReferences), - })), - messageForksHash: session.messageForksHash + const messageForkEntries = Object.entries(session.messageForksHash ?? {}) + const messageForksHash = + messageForkEntries.length > 0 ? Object.fromEntries( - Object.entries(session.messageForksHash).map(([key, fork]) => [ + messageForkEntries.map(([key, fork]) => [ key, { ...fork, @@ -118,7 +137,17 @@ function stripLocalSessionReferences(session: Session): Session { }, ]) ) - : undefined, + : undefined + + return { + ...sessionWithoutLocalImages, + backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, + messages: session.messages.map(stripLocalMessageReferences), + threads: session.threads?.map((thread) => ({ + ...thread, + messages: thread.messages.map(stripLocalMessageReferences), + })), + messageForksHash, } } @@ -169,6 +198,22 @@ function metaForCopiedSession( } } +function applyMetaToSession(session: Session, meta: SessionMetaRecord | undefined): Session { + if (!meta) { + return session + } + return { + ...session, + name: meta.name, + type: session.type, + starred: meta.starred, + hidden: meta.hidden, + assistantAvatarKey: meta.assistantAvatarKey, + picUrl: meta.picUrl, + backgroundImage: meta.backgroundImage, + } +} + export function createSyncSnapshot(input: { sessions: Session[] metas: SessionMetaRecord[] @@ -219,24 +264,33 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot const localSession = localSessionById.get(remoteSession.id) const remoteMeta = remoteMetaById.get(remoteSession.id) + const remoteSessionWithMeta = applyMetaToSession(remoteSession, remoteMeta) if (!localSession) { - sessionsToSave.push(remoteSession) - metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + sessionsToSave.push(remoteSessionWithMeta) + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) imported += 1 continue } - if (sessionsEqual(localSession, remoteSession)) { - if (!localMetaById.has(remoteSession.id) && remoteMeta) { - metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + if (sessionContentEqual(localSession, remoteSession)) { + if (input.preferRemoteMetadata) { + if (!sessionsEqual(localSession, remoteSessionWithMeta)) { + sessionsToSave.push(remoteSessionWithMeta) + } + const localMeta = localMetaById.get(remoteSession.id) + if (remoteMeta && (!localMeta || !metasEqual(localMeta, remoteMeta))) { + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) + } + } else if (!localMetaById.has(remoteSession.id) && remoteMeta) { + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) } continue } - const copiedName = copyName(remoteSession.name) + const copiedName = copyName(remoteSessionWithMeta.name) const copiedSession: Session = { - ...remoteSession, + ...remoteSessionWithMeta, id: input.createId(), name: copiedName, } diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts index 8ac4098a0d..e433d38b9b 100644 --- a/src/renderer/packages/sync/types.ts +++ b/src/renderer/packages/sync/types.ts @@ -1,4 +1,5 @@ import type { Session, SessionMetaRecord } from '@shared/types' + export type { WebDAVMethod, WebDAVRequest, WebDAVResponse } from '@shared/sync-webdav' export type SyncProvider = 'webdav' @@ -27,6 +28,7 @@ export type MergeRemoteSnapshotInput = { remote: SyncSnapshot now: number createId: () => string + preferRemoteMetadata?: boolean } export type MergeRemoteSnapshotResult = { From 700ef0a500d953baa62482dad227e192f90e77cd Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 12:14:03 +0800 Subject: [PATCH 6/6] Use conditional WebDAV snapshot uploads --- src/renderer/packages/sync/service.test.ts | 111 +++++++++++++++++- src/renderer/packages/sync/service.ts | 124 ++++++++++++++------- src/renderer/packages/sync/webdav.test.ts | 9 ++ src/shared/sync-webdav.ts | 7 +- 4 files changed, 207 insertions(+), 44 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 99f5aa753a..42e0239c59 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -2,7 +2,7 @@ import type { Session, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' -import type { SyncSnapshot, WebDAVRequest } from './types' +import type { SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' const baseSettings = { sync: { @@ -73,6 +73,8 @@ describe('WebDAV sync service', () => { expect(result.uploaded).toBe(1) expect(put?.url).toBe('https://dav.example.com/files/me/ChatboxSync/v1/snapshot.json.enc') expect(put?.headers?.Authorization).toBe(`Basic ${btoa('alice:app-password')}`) + expect(put?.headers?.['If-None-Match']).toBe('*') + expect(put?.headers?.['If-Match']).toBeUndefined() expect(put?.body).not.toContain('hello') const envelope = JSON.parse(put?.body ?? '{}') @@ -97,7 +99,11 @@ describe('WebDAV sync service', () => { webdavRequest: vi.fn((request: WebDAVRequest) => { requests.push(request) if (request.method === 'GET') { - return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + return Promise.resolve({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) } return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) }), @@ -118,12 +124,113 @@ describe('WebDAV sync service', () => { const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') expect(result.uploaded).toBe(2) + expect(put?.headers?.['If-Match']).toBe('"remote-etag"') + expect(put?.headers?.['If-None-Match']).toBeUndefined() expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(deps.saveSession).not.toHaveBeenCalled() expect(deps.saveMetas).not.toHaveBeenCalled() }) + it('re-downloads and merges again when a conditional upload detects a changed remote snapshot', async () => { + const requests: WebDAVRequest[] = [] + const remoteBeforeRace: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'remote text 1')], + metas: [meta('remote-1', 'Remote 1')], + } + const remoteAfterRace: SyncSnapshot = { + ...remoteBeforeRace, + sessions: [...remoteBeforeRace.sessions, session('remote-2', 'Remote 2', 'remote text 2')], + metas: [...remoteBeforeRace.metas, meta('remote-2', 'Remote 2', 2)], + } + let getCount = 0 + let putCount = 0 + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn(async (request: WebDAVRequest): Promise => { + requests.push(request) + if (request.method === 'GET') { + getCount += 1 + const remote = getCount === 1 ? remoteBeforeRace : remoteAfterRace + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + return { + status: 200, + headers: { etag: getCount === 1 ? '"old-etag"' : '"new-etag"' }, + body: JSON.stringify(envelope), + } + } + if (request.method === 'PUT') { + putCount += 1 + return { status: putCount === 1 ? 412 : 201, headers: {}, body: '' } + } + return { status: 405, headers: {}, body: '' } + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const puts = requests.filter((request) => request.method === 'PUT') + const finalEnvelope = JSON.parse(puts[1]?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(finalEnvelope, 'sync-secret') + + expect(result.uploaded).toBe(3) + expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2) + expect(puts).toHaveLength(2) + expect(puts[0].headers?.['If-Match']).toBe('"old-etag"') + expect(puts[1].headers?.['If-Match']).toBe('"new-etag"') + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1', 'remote-2']) + expect(deps.updateLastSyncedAt).toHaveBeenCalledTimes(1) + }) + + it('refuses to overwrite an existing remote snapshot when the server does not return an ETag', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + await expect(uploadWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/etag/i) + + expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(0) + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 3336f1d7e8..fdf67c57e8 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -15,11 +15,20 @@ const RawSyncSnapshotSchema = z.object({ metas: z.array(SessionMetaRecordSchema), }) +const MAX_UPLOAD_ATTEMPTS = 3 +const HTTP_NOT_FOUND = 404 +const HTTP_PRECONDITION_FAILED = 412 + type SyncPlatform = { getDeviceName?: () => Promise webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise } +type DownloadedWebDAVSnapshot = { + snapshot?: SyncSnapshot + etag?: string +} + export type WebDAVSyncDeps = { platform: SyncPlatform listLocalSessions: () => Promise @@ -108,6 +117,20 @@ function assertSuccess(response: WebDAVResponse, action: string, okStatuses: num } } +function responseHeader(headers: Record, name: string): string | undefined { + const normalizedName = name.toLowerCase() + const entry = Object.entries(headers).find(([headerName]) => headerName.toLowerCase() === normalizedName) + const value = entry?.[1].trim() + return value ? value : undefined +} + +function requireWebDAVSnapshotETag(remote: DownloadedWebDAVSnapshot): string { + if (!remote.etag) { + throw new Error('WebDAV server did not return an ETag for the sync snapshot; refusing to overwrite it') + } + return remote.etag +} + async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatform) { const webdav = getWebDAVSettings(settings) const headers = authHeaders(settings) @@ -121,7 +144,7 @@ async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatfor } } -async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { +async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { const webdav = getWebDAVSettings(settings) const response = await requestWebDAV(platform, webdav.url, { url: snapshotUrl(settings), @@ -129,13 +152,16 @@ async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform headers: authHeaders(settings), }) - if (response.status === 404) { - return undefined + if (response.status === HTTP_NOT_FOUND) { + return {} } assertSuccess(response, 'Download WebDAV sync snapshot', [200]) const envelope = JSON.parse(response.body) as SyncCryptoEnvelope - return parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) + return { + snapshot: parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)), + etag: responseHeader(response.headers, 'etag'), + } } export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { @@ -157,6 +183,7 @@ export async function uploadWebDAVSnapshot( deps: WebDAVSyncDeps ): Promise { const webdav = getWebDAVSettings(settings) + const now = deps.now ?? Date.now await ensureWebDAVCollections(settings, deps.platform) const [sessions, metas, deviceName] = await Promise.all([ @@ -164,47 +191,64 @@ export async function uploadWebDAVSnapshot( deps.listLocalMetas(), deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) - const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + const lastSyncedAt = new Date(now()).toISOString() const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, }) - const remoteSnapshot = await downloadWebDAVSnapshot(settings, deps.platform) - const mergeResult = remoteSnapshot - ? mergeRemoteSnapshot({ - localSessions: localSnapshot.sessions, - localMetas: localSnapshot.metas, - remote: remoteSnapshot, - now: (deps.now ?? Date.now)(), - createId: deps.createId, - }) - : undefined - const snapshot = mergeResult - ? createSyncSnapshot({ - sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], - metas: [...localSnapshot.metas, ...mergeResult.metasToSave], - deviceName, - exportedAt: lastSyncedAt, - }) - : localSnapshot - const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) - const response = await requestWebDAV(deps.platform, webdav.url, { - url: snapshotUrl(settings), - method: 'PUT', - headers: { - ...authHeaders(settings), - 'Content-Type': 'application/json', - }, - body: JSON.stringify(envelope), - }) - assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) - await deps.updateLastSyncedAt(lastSyncedAt) - return { - uploaded: snapshot.sessions.length, - lastSyncedAt, + + for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { + const remote = await downloadWebDAVSnapshot(settings, deps.platform) + const mergeResult = remote.snapshot + ? mergeRemoteSnapshot({ + localSessions: localSnapshot.sessions, + localMetas: localSnapshot.metas, + remote: remote.snapshot, + now: now(), + createId: deps.createId, + }) + : undefined + const snapshot = mergeResult + ? createSyncSnapshot({ + sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], + metas: [...localSnapshot.metas, ...mergeResult.metasToSave], + deviceName, + exportedAt: lastSyncedAt, + }) + : localSnapshot + const preconditionHeaders: Record = remote.snapshot + ? { 'If-Match': requireWebDAVSnapshotETag(remote) } + : { 'If-None-Match': '*' } + const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PUT', + headers: { + ...authHeaders(settings), + 'Content-Type': 'application/json', + ...preconditionHeaders, + }, + body: JSON.stringify(envelope), + }) + + if (response.status === HTTP_PRECONDITION_FAILED) { + if (attempt < MAX_UPLOAD_ATTEMPTS - 1) { + continue + } + throw new Error('Upload WebDAV sync snapshot failed because the remote snapshot changed during upload') + } + + assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) + await deps.updateLastSyncedAt(lastSyncedAt) + return { + uploaded: snapshot.sessions.length, + lastSyncedAt, + } } + + throw new Error('Upload WebDAV sync snapshot failed because the remote snapshot changed during upload') } export async function downloadAndMergeWebDAVSnapshot( @@ -212,7 +256,7 @@ export async function downloadAndMergeWebDAVSnapshot( deps: WebDAVSyncDeps ): Promise { const remote = await downloadWebDAVSnapshot(settings, deps.platform) - if (!remote) { + if (!remote.snapshot) { return { imported: 0, conflicts: 0, @@ -225,7 +269,7 @@ export async function downloadAndMergeWebDAVSnapshot( const result = mergeRemoteSnapshot({ localSessions, localMetas, - remote, + remote: remote.snapshot, now: (deps.now ?? Date.now)(), createId: deps.createId, preferRemoteMetadata: true, diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts index b81f95d8fe..b6c77aef4e 100644 --- a/src/renderer/packages/sync/webdav.test.ts +++ b/src/renderer/packages/sync/webdav.test.ts @@ -64,6 +64,15 @@ describe('WebDAV helpers', () => { }) ).not.toThrow() + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { 'If-Match': '"abc"', 'If-None-Match': '*' }, + body: 'payload', + }) + ).not.toThrow() + expect(() => validateWebDAVRequestTarget(baseUrl, { url: 'https://dav.example.com/remote.php/dav/files/me/other.json', diff --git a/src/shared/sync-webdav.ts b/src/shared/sync-webdav.ts index 50a80b61d8..3918b6c987 100644 --- a/src/shared/sync-webdav.ts +++ b/src/shared/sync-webdav.ts @@ -31,7 +31,7 @@ const ALLOWED_WEBDAV_TARGETS = new Map>([ [SYNC_COLLECTION_PATH, new Set(['MKCOL'])], [SYNC_SNAPSHOT_PATH, new Set(['GET', 'PUT', 'PROPFIND'])], ]) -const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth']) +const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth', 'if-match', 'if-none-match']) const REDIRECT_STATUS_MIN = 300 const REDIRECT_STATUS_MAX = 399 @@ -54,7 +54,10 @@ export function validateWebDAVRequestTarget(baseUrl: string, request: WebDAVRequ if (typeof request.url !== 'string' || typeof request.method !== 'string') { rejectWebDAVRequest() } - if (request.headers !== undefined && (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers))) { + if ( + request.headers !== undefined && + (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers)) + ) { rejectWebDAVRequest() } if (request.body !== undefined && typeof request.body !== 'string') {