From a8a1ad1d548ed2b084c2966460a3d03f0c032399 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:22:47 +0100 Subject: [PATCH] feat: implement settings synchronization with server, including encryption and API endpoints --- .env.example | 22 ++++++++ app/api/config/route.ts | 1 + app/api/settings/route.ts | 103 ++++++++++++++++++++++++++++++++++++++ hooks/use-config.ts | 4 ++ lib/settings-sync.ts | 83 ++++++++++++++++++++++++++++++ stores/auth-store.ts | 23 +++++++++ stores/settings-store.ts | 75 +++++++++++++++++++++++++++ 7 files changed, 311 insertions(+) create mode 100644 app/api/settings/route.ts create mode 100644 lib/settings-sync.ts diff --git a/.env.example b/.env.example index 418c1a22..8cdabdc4 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,25 @@ LOG_LEVEL=info # # Then run: # docker compose up -d + +# ============================================================================= +# Session & Security +# ============================================================================= + +# Secret key for encrypting "Remember me" sessions and settings sync data +# Required for both "Remember me" and settings sync features +# Use a strong random string (e.g. openssl rand -base64 32) +# SESSION_SECRET=your-secret-key-here + +# ============================================================================= +# Settings Sync +# ============================================================================= + +# Enable server-side settings persistence (requires SESSION_SECRET) +# When enabled, user settings are encrypted and stored on the server, +# allowing them to sync across browsers and devices. +# SETTINGS_SYNC_ENABLED=true + +# Directory for storing encrypted settings files (default: ./data/settings) +# For Docker, mount a persistent volume at this path. +# SETTINGS_DATA_DIR=./data/settings diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 82387707..71e9b9fb 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -22,6 +22,7 @@ export async function GET() { oauthClientId: process.env.OAUTH_CLIENT_ID || '', oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '', rememberMeEnabled: !!process.env.SESSION_SECRET, + settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET, devMode: process.env.DEV_MOCK_JMAP === 'true', }); } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 00000000..7210b410 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { logger } from '@/lib/logger'; +import { decryptSession } from '@/lib/auth/crypto'; +import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; +import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; + +function isEnabled(): boolean { + return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET; +} + +/** + * Verify identity against the session cookie if available. + * Returns true if no session cookie exists (can't verify) or if identity matches. + * Returns false if session cookie exists but identity doesn't match. + */ +async function verifyIdentity(username: string, serverUrl: string): Promise { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get(SESSION_COOKIE)?.value; + if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies) + + const session = decryptSession(sessionToken); + if (!session) return true; // Invalid session cookie, skip verification + + return session.username === username && session.serverUrl === serverUrl; +} + +export async function GET(request: NextRequest) { + if (!isEnabled()) { + return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); + } + + const username = request.headers.get('x-settings-username'); + const serverUrl = request.headers.get('x-settings-server'); + if (!username || !serverUrl) { + return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 }); + } + + if (!(await verifyIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + try { + const settings = await loadUserSettings(username, serverUrl); + if (!settings) { + return NextResponse.json({ error: 'No settings found' }, { status: 404 }); + } + return NextResponse.json({ settings }); + } catch (error) { + logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + if (!isEnabled()) { + return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); + } + + try { + const { username, serverUrl, settings } = await request.json(); + if (!username || !serverUrl || !settings) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 }); + } + + if (!(await verifyIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + await saveUserSettings(username, serverUrl, settings); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + if (!isEnabled()) { + return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); + } + + try { + const { username, serverUrl } = await request.json(); + if (!username || !serverUrl) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + if (!(await verifyIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + await deleteUserSettings(username, serverUrl); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 3f7b1ccf..a5aa80b6 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -9,6 +9,7 @@ interface ConfigData { oauthClientId: string; oauthIssuerUrl: string; rememberMeEnabled: boolean; + settingsSyncEnabled: boolean; devMode: boolean; } @@ -66,6 +67,7 @@ export function useConfig(): AppConfig { oauthClientId: configCache?.oauthClientId || '', oauthIssuerUrl: configCache?.oauthIssuerUrl || '', rememberMeEnabled: configCache?.rememberMeEnabled || false, + settingsSyncEnabled: configCache?.settingsSyncEnabled || false, devMode: configCache?.devMode || false, isLoading: !configCache, error: null, @@ -81,6 +83,7 @@ export function useConfig(): AppConfig { oauthClientId: configCache.oauthClientId, oauthIssuerUrl: configCache.oauthIssuerUrl, rememberMeEnabled: configCache.rememberMeEnabled, + settingsSyncEnabled: configCache.settingsSyncEnabled, devMode: configCache.devMode, isLoading: false, error: null, @@ -97,6 +100,7 @@ export function useConfig(): AppConfig { oauthClientId: data.oauthClientId, oauthIssuerUrl: data.oauthIssuerUrl, rememberMeEnabled: data.rememberMeEnabled, + settingsSyncEnabled: data.settingsSyncEnabled, devMode: data.devMode, isLoading: false, error: null, diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts new file mode 100644 index 00000000..b4ac52c2 --- /dev/null +++ b/lib/settings-sync.ts @@ -0,0 +1,83 @@ +import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + const secret = process.env.SESSION_SECRET; + if (!secret) throw new Error('SESSION_SECRET not configured'); + return createHash('sha256').update(secret).digest(); +} + +function getSettingsDir(): string { + return process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'); +} + +function getSettingsPath(username: string, serverUrl: string): string { + const hash = createHash('sha256').update(`${username}:${serverUrl}`).digest('hex'); + const filePath = path.join(getSettingsDir(), `${hash}.enc`); + // Defense in depth: ensure path stays within the settings directory + const resolvedDir = path.resolve(getSettingsDir()); + const resolvedPath = path.resolve(filePath); + if (!resolvedPath.startsWith(resolvedDir + path.sep) && resolvedPath !== resolvedDir) { + throw new Error('Invalid settings path'); + } + return resolvedPath; +} + +export async function saveUserSettings(username: string, serverUrl: string, settings: Record): Promise { + const dir = getSettingsDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const payload = JSON.stringify(settings); + const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + const data = Buffer.concat([iv, tag, encrypted]); + await writeFile(getSettingsPath(username, serverUrl), data); +} + +export async function loadUserSettings(username: string, serverUrl: string): Promise | null> { + const filePath = getSettingsPath(username, serverUrl); + + try { + const data = await readFile(filePath); + if (data.length < IV_LENGTH + TAG_LENGTH) return null; + + const key = getKey(); + const iv = data.subarray(0, IV_LENGTH); + const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + logger.warn('Failed to load user settings', { error: error instanceof Error ? error.message : 'Unknown error' }); + return null; + } +} + +export async function deleteUserSettings(username: string, serverUrl: string): Promise { + try { + await unlink(getSettingsPath(username, serverUrl)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('Failed to delete user settings', { error: error instanceof Error ? error.message : 'Unknown error' }); + } + } +} diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 93a1c8ff..54712c89 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -7,6 +7,7 @@ import { useContactStore } from './contact-store'; import { useVacationStore } from './vacation-store'; import { useCalendarStore } from './calendar-store'; import { useFilterStore } from './filter-store'; +import { useSettingsStore } from './settings-store'; import { debug } from '@/lib/debug'; import type { Identity } from '@/lib/jmap/types'; @@ -156,6 +157,11 @@ export const useAuthStore = create()( error: null, }); + // Sync settings from server + useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { + useSettingsStore.getState().enableSync(username, serverUrl); + }); + if (rememberMe) { try { const res = await fetch('/api/auth/session', { @@ -226,6 +232,11 @@ export const useAuthStore = create()( scheduleRefresh(expires_in, get().refreshAccessToken); + // Sync settings from server + useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { + useSettingsStore.getState().enableSync(username, serverUrl); + }); + return true; } catch (error) { debug.error('OAuth login error:', error); @@ -283,6 +294,8 @@ export const useAuthStore = create()( clearRefreshTimer(); state.client?.disconnect(); + useSettingsStore.getState().disableSync(); + set({ isAuthenticated: false, serverUrl: null, @@ -365,6 +378,11 @@ export const useAuthStore = create()( primaryIdentity, accessToken: token, }); + + // Sync settings from server + useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl).finally(() => { + useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!); + }); return; } } catch (error) { @@ -400,6 +418,11 @@ export const useAuthStore = create()( primaryIdentity, authMode: 'basic', }); + + // Sync settings from server + useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { + useSettingsStore.getState().enableSync(username, serverUrl); + }); return; } } catch (error) { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 10fd7dd4..b07a6cad 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -1,6 +1,15 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +// Settings sync state (module-level, not persisted) +let syncEnabled = false; +let syncUsername: string | null = null; +let syncServerUrl: string | null = null; +let syncTimeout: ReturnType | null = null; +let isLoadingFromServer = false; + +const SYNC_DEBOUNCE_MS = 2000; + export type FontSize = 'small' | 'medium' | 'large'; export type ListDensity = 'compact' | 'regular' | 'comfortable'; export type DeleteAction = 'trash' | 'permanent'; @@ -67,6 +76,11 @@ interface SettingsState { addTrustedSender: (email: string) => void; removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; + + // Settings sync + enableSync: (username: string, serverUrl: string) => void; + disableSync: () => void; + loadFromServer: (username: string, serverUrl: string) => Promise; } const DEFAULT_SETTINGS = { @@ -227,6 +241,46 @@ export const useSettingsStore = create()( const normalizedEmail = email.toLowerCase().trim(); return get().trustedSenders.includes(normalizedEmail); }, + + // Settings sync methods + enableSync: (username: string, serverUrl: string) => { + syncUsername = username; + syncServerUrl = serverUrl; + syncEnabled = true; + }, + + disableSync: () => { + syncEnabled = false; + syncUsername = null; + syncServerUrl = null; + if (syncTimeout) { + clearTimeout(syncTimeout); + syncTimeout = null; + } + }, + + loadFromServer: async (username: string, serverUrl: string) => { + try { + const res = await fetch('/api/settings', { + headers: { + 'x-settings-username': username, + 'x-settings-server': serverUrl, + }, + }); + if (!res.ok) return false; + const { settings } = await res.json(); + if (settings && typeof settings === 'object') { + isLoadingFromServer = true; + get().importSettings(JSON.stringify(settings)); + isLoadingFromServer = false; + return true; + } + return false; + } catch { + isLoadingFromServer = false; + return false; + } + }, }), { name: 'settings-storage', @@ -277,4 +331,25 @@ if (typeof window !== 'undefined') { applyFontSize(store.fontSize); applyListDensity(store.listDensity); applyAnimations(store.animationsEnabled); + + // Auto-sync settings to server on any state change + useSettingsStore.subscribe(() => { + if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return; + if (syncTimeout) clearTimeout(syncTimeout); + syncTimeout = setTimeout(async () => { + try { + const settings = JSON.parse(useSettingsStore.getState().exportSettings()); + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }), + }); + if (res.status === 404) { + syncEnabled = false; + } + } catch { + // Silently ignore sync failures + } + }, SYNC_DEBOUNCE_MS); + }); }