feat: implement settings synchronization with server, including encryption and API endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user