import {app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, Menu, nativeImage, protocol, safeStorage, shell} from 'electron'; import * as path from 'path'; import {fileURLToPath} from 'url'; import * as fs from 'fs'; import {DatabaseService, getDatabaseService} from './database/database.service.js'; import SecureStorage, {getSecureStorage} from './storage/SecureStorage.js'; import {getUserEncryptionKey, hasUserEncryptionKey, setUserEncryptionKey} from './database/keyManager.js'; import {generateUserEncryptionKey} from './database/encryption.js'; // Import IPC handlers import './ipc/book.ipc.js'; import './ipc/user.ipc.js'; import './ipc/chapter.ipc.js'; import './ipc/character.ipc.js'; import './ipc/location.ipc.js'; import './ipc/offline.ipc.js'; // Fix pour __dirname en ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const isDev = !app.isPackaged; // Enregistrer le protocole scribedesktop:// comme standard (avant app.whenReady) if (!isDev) { protocol.registerSchemesAsPrivileged([ { scheme: 'scribedesktop', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } } ]); } // Définir le nom de l'application app.setName('ERitors Scribe'); // En dev et prod, __dirname pointe vers dist/electron/ const preloadPath = path.join(__dirname, 'preload.js'); // Icône de l'application const iconPath = isDev ? path.join(__dirname, '../build/icon.png') : process.platform === 'darwin' ? path.join(process.resourcesPath, 'icon.icns') // macOS utilise .icns : path.join(process.resourcesPath, 'app.asar/build/icon.png'); // Windows/Linux utilisent .png let mainWindow: BrowserWindow | null = null; let loginWindow: BrowserWindow | null = null; function createLoginWindow(): void { loginWindow = new BrowserWindow({ width: 500, height: 900, resizable: false, ...(process.platform !== 'darwin' && { icon: iconPath }), autoHideMenuBar: true, webPreferences: { preload: preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, experimentalFeatures: false, enableBlinkFeatures: '', disableBlinkFeatures: '', webviewTag: false, navigateOnDragDrop: false, }, frame: true, show: false, }); if (isDev) { const devPort = process.env.PORT || '4000'; loginWindow.loadURL(`http://localhost:${devPort}/login/login`); loginWindow.webContents.openDevTools(); } else { loginWindow.loadURL('scribedesktop://./login/login/index.html'); } loginWindow.once('ready-to-show', () => { loginWindow?.show(); }); loginWindow.on('closed', () => { loginWindow = null; }); // Security: Block navigation to external domains loginWindow.webContents.on('will-navigate', (event, navigationUrl) => { const parsedUrl = new URL(navigationUrl); if (isDev) { if (!parsedUrl.origin.startsWith('http://localhost')) { event.preventDefault(); } } else { if (parsedUrl.protocol !== 'scribedesktop:') { event.preventDefault(); } } }); // Security: Block new window creation loginWindow.webContents.setWindowOpenHandler(() => { return { action: 'deny' }; }); } function createMainWindow(): void { mainWindow = new BrowserWindow({ width: 1200, height: 800, ...(process.platform !== 'darwin' && { icon: iconPath }), autoHideMenuBar: true, webPreferences: { preload: preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, experimentalFeatures: false, enableBlinkFeatures: '', disableBlinkFeatures: '', webviewTag: false, navigateOnDragDrop: false, }, show: false, }); if (isDev) { const devPort = process.env.PORT || '4000'; mainWindow.loadURL(`http://localhost:${devPort}`); mainWindow.webContents.openDevTools(); } else { mainWindow.loadURL('scribedesktop://./index.html'); } mainWindow.once('ready-to-show', () => { mainWindow?.show(); }); mainWindow.on('closed', () => { mainWindow = null; }); // Security: Block navigation to external domains mainWindow.webContents.on('will-navigate', (event, navigationUrl) => { const parsedUrl = new URL(navigationUrl); if (isDev) { if (!parsedUrl.origin.startsWith('http://localhost')) { event.preventDefault(); } } else { if (parsedUrl.protocol !== 'scribedesktop:') { event.preventDefault(); } } }); // Security: Block new window creation mainWindow.webContents.setWindowOpenHandler(() => { return { action: 'deny' }; }); } // IPC Handler pour ouvrir des liens externes (navigateur/app native) ipcMain.handle('open-external', async (_event, url: string) => { // Security: Validate URL before opening try { const parsedUrl = new URL(url); const allowedProtocols = ['http:', 'https:', 'mailto:']; if (!allowedProtocols.includes(parsedUrl.protocol)) { console.error('[Security] Blocked external URL with invalid protocol:', parsedUrl.protocol); return; } await shell.openExternal(url); } catch (error) { console.error('[Security] Invalid URL rejected:', url); } }); // IPC Handler pour OAuth login via BrowserWindow let oauthWindow: BrowserWindow | null = null; interface OAuthResult { success: boolean; code?: string; state?: string; error?: string; } interface OAuthRequest { provider: 'google' | 'facebook' | 'apple'; baseUrl: string; } ipcMain.handle('oauth-login', async (_event, request: OAuthRequest): Promise => { return new Promise((resolve) => { const { provider, baseUrl } = request; const redirectUri = `${baseUrl}login?provider=${provider}`; const encodedRedirectUri = encodeURIComponent(redirectUri); // Fermer une éventuelle fenêtre OAuth existante if (oauthWindow) { oauthWindow.close(); oauthWindow = null; } // Configuration OAuth par provider const oauthConfigs: Record = { google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=911482317931-pvjog1br22r6l8k1afq0ki94em2fsoen.apps.googleusercontent.com&redirect_uri=${encodedRedirectUri}&response_type=code&scope=openid%20email%20profile&access_type=offline`, facebook: `https://www.facebook.com/v18.0/dialog/oauth?client_id=1015270470233591&redirect_uri=${encodedRedirectUri}&scope=email&response_type=code&state=abc123`, apple: `https://appleid.apple.com/auth/authorize?client_id=eritors.apple.login&redirect_uri=${encodedRedirectUri}&response_type=code&scope=email%20name&response_mode=query&state=abc123` }; const authUrl = oauthConfigs[provider]; if (!authUrl) { resolve({ success: false, error: 'Invalid provider' }); return; } oauthWindow = new BrowserWindow({ width: 600, height: 700, show: true, autoHideMenuBar: true, webPreferences: { nodeIntegration: false, contextIsolation: true, } }); // Intercepter les redirections pour capturer le code OAuth const handleNavigation = (url: string): boolean => { try { const parsedUrl = new URL(url); // Vérifier si c'est notre redirect URI (compare sans le query string) const baseRedirectUri = `${baseUrl}login`; if (url.startsWith(baseRedirectUri)) { const code = parsedUrl.searchParams.get('code'); const state = parsedUrl.searchParams.get('state'); const error = parsedUrl.searchParams.get('error'); if (error) { resolve({ success: false, error }); } else if (code) { resolve({ success: true, code, state: state || undefined }); } else { resolve({ success: false, error: 'No code received' }); } if (oauthWindow) { oauthWindow.close(); oauthWindow = null; } return true; // Navigation interceptée } } catch (e) { // URL invalide, continuer } return false; // Laisser la navigation continuer }; // Écouter will-redirect (redirections HTTP) oauthWindow.webContents.on('will-redirect', (event, url) => { if (handleNavigation(url)) { event.preventDefault(); } }); // Écouter will-navigate (navigations normales) oauthWindow.webContents.on('will-navigate', (event, url) => { if (handleNavigation(url)) { event.preventDefault(); } }); // Gérer la fermeture de la fenêtre par l'utilisateur oauthWindow.on('closed', () => { oauthWindow = null; resolve({ success: false, error: 'Window closed by user' }); }); // Charger l'URL OAuth oauthWindow.loadURL(authUrl); }); }); // IPC Handlers pour la gestion du token (OS-encrypted storage) ipcMain.handle('get-token', () => { const storage:SecureStorage = getSecureStorage(); return storage.get('authToken', null); }); ipcMain.handle('set-token', (_event, token: string) => { const storage = getSecureStorage(); storage.set('authToken', token); return true; }); ipcMain.handle('remove-token', () => { const storage = getSecureStorage(); storage.delete('authToken'); return true; }); // IPC Handlers pour la gestion de la langue ipcMain.handle('get-lang', () => { const storage = getSecureStorage(); return storage.get('userLang', 'fr'); }); ipcMain.handle('set-lang', (_event, lang: 'fr' | 'en') => { const storage = getSecureStorage(); storage.set('userLang', lang); return true; }); // IPC Handler pour initialiser l'utilisateur après récupération depuis le serveur ipcMain.handle('init-user', async (_event:IpcMainInvokeEvent, userId: string) => { const storage:SecureStorage = getSecureStorage(); storage.set('userId', userId); storage.set('lastUserId', userId); try { let encryptionKey: string | null; if (!hasUserEncryptionKey(userId)) { encryptionKey = generateUserEncryptionKey(userId); if (!encryptionKey) { throw new Error('Failed to generate encryption key'); } setUserEncryptionKey(userId, encryptionKey); const savedKey:string = getUserEncryptionKey(userId); if (!savedKey) { throw new Error('Failed to save encryption key'); } } else { encryptionKey = getUserEncryptionKey(userId); if (!encryptionKey) { console.error('[InitUser] CRITICAL: Existing key is undefined, regenerating'); const { generateUserEncryptionKey } = await import('./database/encryption.js'); encryptionKey = generateUserEncryptionKey(userId); setUserEncryptionKey(userId, encryptionKey); } } if (safeStorage.isEncryptionAvailable()) { storage.save(); } else { return { success: false, error: 'Encryption is not available on this system' }; } return { success: true, keyCreated: !hasUserEncryptionKey(userId) }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } }); ipcMain.on('login-success', async (_event, token: string) => { const storage = getSecureStorage(); storage.set('authToken', token); if (loginWindow) { loginWindow.close(); } createMainWindow(); setTimeout(async ():Promise => { try { if (safeStorage.isEncryptionAvailable()) { storage.save(); } else { setTimeout(() => { if (safeStorage.isEncryptionAvailable()) { storage.save(); } else { console.error('[Login] CRITICAL: Cannot encrypt credentials'); } }, 1000); } } catch (error) { console.error('[Login] Error saving auth data:', error); } }, 500); }); ipcMain.on('logout', ():void => { try { const storage:SecureStorage = getSecureStorage(); storage.delete('authToken'); storage.delete('userId'); storage.delete('userLang'); storage.save(); } catch (error) { console.error('[Logout] Error clearing storage:', error); } try { const db:DatabaseService = getDatabaseService(); db.close(); } catch (error) { console.error('[Logout] Error closing database:', error); } if (mainWindow) { mainWindow.close(); mainWindow = null; } createLoginWindow(); }); // ========== USER SYNC (PRE-AUTHENTICATION) ========== interface SyncUserData { userId: string; firstName: string; lastName: string; username: string; email: string; } ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise => { try { const { default: User } = await import('./database/models/User.js'); const { default: UserRepo } = await import('./database/repositories/user.repository.js'); const lang: 'fr' | 'en' = 'fr'; try { UserRepo.fetchUserInfos(data.userId, lang); return true; } catch (error) { await User.addUser( data.userId, data.firstName, data.lastName, data.username, data.email, '', lang ); return true; } } catch (error) { throw error; } }); /** * Generate user encryption key */ ipcMain.handle('generate-encryption-key', async (_event, userId: string) => { try { const key:string = generateUserEncryptionKey(userId); return { success: true, key }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } }); /** * Get or generate user encryption key (OS-encrypted storage) */ ipcMain.handle('get-user-encryption-key', (_event, userId: string) => { const storage:SecureStorage = getSecureStorage(); return storage.get(`encryptionKey-${userId}`, null); }); /** * Store user encryption key (OS-encrypted storage) */ ipcMain.handle('set-user-encryption-key', (_event, userId: string, encryptionKey: string) => { const storage:SecureStorage = getSecureStorage(); storage.set(`encryptionKey-${userId}`, encryptionKey); return true; }); /** * Initialize database for user */ ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string) => { try { const db:DatabaseService = getDatabaseService(); db.initialize(userId, encryptionKey); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } }); /** * Emergency restore - Clean up ALL local data */ function performEmergencyRestore(): void { try { // Close database connection const db: DatabaseService = getDatabaseService(); db.close(); // Get storage and userId before clearing const storage: SecureStorage = getSecureStorage(); const userId = storage.get('userId'); const lastUserId = storage.get('lastUserId'); // Delete user-specific data if (userId) { storage.delete(`pin-${userId}`); storage.delete(`encryptionKey-${userId}`); } if (lastUserId && lastUserId !== userId) { storage.delete(`pin-${lastUserId}`); storage.delete(`encryptionKey-${lastUserId}`); } // Delete all general data storage.delete('authToken'); storage.delete('userId'); storage.delete('lastUserId'); storage.delete('userLang'); storage.delete('offlineMode'); storage.delete('syncInterval'); // Save cleared storage storage.save(); // Delete database file const userDataPath: string = app.getPath('userData'); const dbPath: string = path.join(userDataPath, 'eritors-local.db'); if (fs.existsSync(dbPath)) { fs.unlinkSync(dbPath); } // Delete secure config file to ensure complete reset const secureConfigPath: string = path.join(userDataPath, 'secure-config.json'); if (fs.existsSync(secureConfigPath)) { fs.unlinkSync(secureConfigPath); } console.log('[Emergency Restore] All local data cleared successfully'); } catch (error) { console.error('[Emergency Restore] Error:', error); } // Restart app if (mainWindow) { mainWindow.close(); mainWindow = null; } if (loginWindow) { loginWindow.close(); loginWindow = null; } app.relaunch(); app.exit(0); } app.whenReady().then(():void => { // Security: Disable web cache in production if (!isDev) { app.commandLine.appendSwitch('disable-http-cache'); } // Security: Set permissions request handler app.on('web-contents-created', (_event, contents) => { // Allow only clipboard permissions, block others contents.session.setPermissionRequestHandler((_webContents, permission, callback) => { const allowedPermissions: string[] = ['clipboard-read', 'clipboard-sanitized-write']; callback(allowedPermissions.includes(permission)); }); // Block all web requests to file:// protocol contents.session.protocol.interceptFileProtocol('file', (request, callback) => { callback({ error: -3 }); // net::ERR_ABORTED }); }); // Menu minimal pour garder les raccourcis DevTools et clipboard const template: Electron.MenuItemConstructorOptions[] = [ { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' } ] }, { label: 'View', submenu: [ { role: 'toggleDevTools' } ] }, { label: 'Help', submenu: [ { label: 'Restore App', click: () => { dialog.showMessageBox({ type: 'warning', buttons: ['Cancel', 'Restore'], defaultId: 0, cancelId: 0, title: 'Restore App', message: 'Are you sure you want to restore the app?', detail: 'This will delete all local data including: PIN codes, encryption keys, local database, and authentication tokens. The app will restart after restoration.' }).then((result) => { if (result.response === 1) { performEmergencyRestore(); } }); } } ] } ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); if (!isDev) { const outPath:string = path.join(process.resourcesPath, 'app.asar.unpacked/out'); protocol.handle('scribedesktop', async (request) => { // Security: Validate and sanitize file path let filePath:string = request.url.replace('scribedesktop://', '').replace(/^\.\//, ''); // Security: Block path traversal attempts if (filePath.includes('..') || filePath.includes('~')) { console.error('[Security] Path traversal attempt blocked:', filePath); return new Response('Forbidden', { status: 403 }); } const fullPath:string = path.normalize(path.join(outPath, filePath)); // Security: Ensure path is within allowed directory if (!fullPath.startsWith(outPath)) { console.error('[Security] Path escape attempt blocked:', fullPath); return new Response('Forbidden', { status: 403 }); } try { const data = await fs.promises.readFile(fullPath); const ext:string = path.extname(fullPath).toLowerCase(); const mimeTypes: Record = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', }; return new Response(data, { headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff' } }); } catch (error) { return new Response('Not found', { status: 404 }); } }); } // Définir l'icône du Dock sur macOS if (process.platform === 'darwin' && app.dock) { const icon = nativeImage.createFromPath(iconPath); app.dock.setIcon(icon); } // Vérifier si un token existe (OS-encrypted storage) const storage: SecureStorage = getSecureStorage(); const token: string | null = storage.get('authToken'); if (token) { createMainWindow(); } else { createLoginWindow(); } app.on('activate', ():void => { if (BrowserWindow.getAllWindows().length === 0) { const storage: SecureStorage = getSecureStorage(); const token: string | null = storage.get('authToken'); if (token) { createMainWindow(); } else { createLoginWindow(); } } }); }); app.on('window-all-closed', ():void => { app.quit(); });