- Introduced `oauthLogin` method in `electron/preload.ts` and backend IPC handlers for OAuth via `BrowserWindow`. - Replaced web-based OAuth redirection with Electron-specific implementation for Google, Facebook, and Apple. - Refactored `SocialForm.tsx` to handle OAuth login success and token management via Electron. - Updated `User`, `QuillSense`, and context methods to include `quill-trial` subscriptions and extended login logic. - Cleaned up code, removed unused imports, and improved error handling for authentication scenarios.
649 lines
18 KiB
TypeScript
649 lines
18 KiB
TypeScript
import {app, BrowserWindow, 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<OAuthResult> => {
|
|
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<string, string> = {
|
|
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<void> => {
|
|
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<boolean> => {
|
|
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'
|
|
};
|
|
}
|
|
});
|
|
|
|
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' }
|
|
]
|
|
}
|
|
];
|
|
|
|
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<string, string> = {
|
|
'.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();
|
|
});
|