Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/packages/sync/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
100 changes: 100 additions & 0 deletions src/renderer/packages/sync/crypto.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> {
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<T>(payload: T, password: string): Promise<SyncCryptoEnvelope> {
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<T = unknown>(envelope: SyncCryptoEnvelope, password: string): Promise<T> {
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)}`)
}
}
6 changes: 6 additions & 0 deletions src/renderer/packages/sync/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './crypto'
export * from './local'
export * from './service'
export * from './snapshot'
export * from './types'
export * from './webdav'
81 changes: 81 additions & 0 deletions src/renderer/packages/sync/local.test.ts
Original file line number Diff line number Diff line change
@@ -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' }])
})
})
68 changes: 68 additions & 0 deletions src/renderer/packages/sync/local.ts
Original file line number Diff line number Diff line change
@@ -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<Session, 'type'> | Pick<SessionMetaRecord, 'type'>): boolean {
return item.type === 'chat' || !item.type
}

export async function listLocalSyncSessions(): Promise<Session[]> {
const metas = (await listAllSessionsMeta()).filter(isChatSessionLike)
const sessions = await Promise.all(
metas.map((meta) => storage.getItem<Session | null>(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<SessionMetaRecord[]> {
return (await listAllSessionsMeta()).filter(isChatSessionLike)
}

export async function saveSyncSession(session: Session): Promise<void> {
await storage.setItemNow(StorageKeyGenerator.session(session.id), session)
}

export async function deleteSyncSession(sessionId: string): Promise<void> {
await storage.removeItem(StorageKeyGenerator.session(sessionId))
}

export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise<void> {
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,
}
}
Loading