feat: implement settings synchronization with server, including encryption and API endpoints

This commit is contained in:
Linus Rath
2026-03-11 16:22:47 +01:00
parent cf5f271be2
commit a8a1ad1d54
7 changed files with 311 additions and 0 deletions
+22
View File
@@ -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
+1
View File
@@ -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',
});
}
+103
View File
@@ -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<boolean> {
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 });
}
}
+4
View File
@@ -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,
+83
View File
@@ -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<string, unknown>): Promise<void> {
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<Record<string, unknown> | 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<void> {
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' });
}
}
}
+23
View File
@@ -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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
clearRefreshTimer();
state.client?.disconnect();
useSettingsStore.getState().disableSync();
set({
isAuthenticated: false,
serverUrl: null,
@@ -365,6 +378,11 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
primaryIdentity,
authMode: 'basic',
});
// Sync settings from server
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
return;
}
} catch (error) {
+75
View File
@@ -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<typeof setTimeout> | 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<boolean>;
}
const DEFAULT_SETTINGS = {
@@ -227,6 +241,46 @@ export const useSettingsStore = create<SettingsState>()(
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);
});
}