Fichier > Génération */ private static function getEncryptionKey() { if (self::$encryption_key !== null) { return self::$encryption_key; } // 1. Variable d'environnement $key = getenv('WEBVAL_API_ENCRYPTION_KEY'); // 2. Fichier externe (hors webroot) if (!$key && file_exists('/etc/webval/encryption.key')) { $key = file_get_contents('/etc/webval/encryption.key'); } // 3. Fichier dans config (fallback) $keyFile = __DIR__ . '/../config/.encryption_key'; if (!$key && file_exists($keyFile)) { $key = file_get_contents($keyFile); } // 4. Générer une nouvelle clé si aucune n'existe if (!$key) { $key = bin2hex(random_bytes(32)); // Essayer de sauvegarder if (is_writable(__DIR__ . '/../config/')) { file_put_contents($keyFile, $key); chmod($keyFile, 0600); } error_log("⚠️ Nouvelle clé de chiffrement générée. Sauvegarde recommandée."); } self::$encryption_key = $key; return $key; } /** * Chiffrer une clé API * * @param string $plaintext Clé API en clair * @return string Clé chiffrée (base64) */ public static function encrypt($plaintext) { if (empty($plaintext)) { return null; } $key = hash('sha256', self::getEncryptionKey(), true); $iv = random_bytes(16); $ciphertext = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); if ($ciphertext === false) { throw new Exception("Erreur de chiffrement"); } // Format: IV (16 bytes) + Ciphertext return base64_encode($iv . $ciphertext); } /** * Déchiffrer une clé API * * @param string $encrypted Clé chiffrée (base64) * @return string|null Clé API en clair */ public static function decrypt($encrypted) { if (empty($encrypted)) { return null; } $key = hash('sha256', self::getEncryptionKey(), true); $data = base64_decode($encrypted); if ($data === false || strlen($data) < 16) { throw new Exception("Données chiffrées invalides"); } $iv = substr($data, 0, 16); $ciphertext = substr($data, 16); $plaintext = openssl_decrypt( $ciphertext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); if ($plaintext === false) { throw new Exception("Erreur de déchiffrement"); } return $plaintext; } /** * Sauvegarder une clé API * * @param int $id_enseignant ID de l'enseignant * @param string $provider Provider (gemini, mistral, etc.) * @param string $api_key Clé API en clair * @param string $model_default Modèle par défaut * @return bool Succès */ public static function saveKey($id_enseignant, $provider, $api_key, $model_default = null) { self::init(); // Chiffrer la clé $encrypted = self::encrypt($api_key); // Upsert (INSERT ... ON DUPLICATE KEY UPDATE) $query = "INSERT INTO api_keys (id_enseignant, provider, api_key_encrypted, model_default, is_active, quota_reset_date) VALUES (?, ?, ?, ?, TRUE, DATE_ADD(CURDATE(), INTERVAL 1 MONTH)) ON DUPLICATE KEY UPDATE api_key_encrypted = VALUES(api_key_encrypted), model_default = VALUES(model_default), is_active = TRUE, updated_at = CURRENT_TIMESTAMP"; return self::$db->query($query, [ $id_enseignant, $provider, $encrypted, $model_default ]); } /** * Récupérer une clé API déchiffrée * * @param int $id_enseignant ID de l'enseignant * @param string $provider Provider * @return array|null ['api_key' => '...', 'model_default' => '...'] */ public static function getKey($id_enseignant, $provider) { self::init(); $result = self::$db->fetchOne( "SELECT api_key_encrypted, model_default, is_active FROM api_keys WHERE id_enseignant = ? AND provider = ?", [$id_enseignant, $provider] ); if (!$result || !$result['is_active']) { return null; } return [ 'api_key' => self::decrypt($result['api_key_encrypted']), 'model_default' => $result['model_default'] ]; } /** * Supprimer une clé API * * @param int $id_enseignant ID de l'enseignant * @param string $provider Provider * @return bool Succès */ public static function deleteKey($id_enseignant, $provider) { self::init(); return self::$db->query( "DELETE FROM api_keys WHERE id_enseignant = ? AND provider = ?", [$id_enseignant, $provider] ); } /** * Lister toutes les clés d'un enseignant * * @param int $id_enseignant ID de l'enseignant * @return array Liste des clés (sans les clés déchiffrées) */ public static function listKeys($id_enseignant) { self::init(); return self::$db->fetchAll( "SELECT id, provider, model_default, is_active, quota_max, quota_used, quota_reset_date, last_used, created_at FROM api_keys WHERE id_enseignant = ? ORDER BY provider", [$id_enseignant] ); } /** * Incrémenter le quota utilisé * * @param int $id_enseignant ID de l'enseignant * @param string $provider Provider * @param int $tokens_used Nombre de tokens utilisés */ public static function incrementQuota($id_enseignant, $provider, $tokens_used = 1) { self::init(); // Reset quota si date dépassée self::$db->query( "UPDATE api_keys SET quota_used = 0, quota_reset_date = DATE_ADD(CURDATE(), INTERVAL 1 MONTH) WHERE id_enseignant = ? AND provider = ? AND quota_reset_date < CURDATE()", [$id_enseignant, $provider] ); // Incrémenter return self::$db->query( "UPDATE api_keys SET quota_used = quota_used + ?, last_used = NOW() WHERE id_enseignant = ? AND provider = ?", [$tokens_used, $id_enseignant, $provider] ); } /** * Vérifier si le quota est dépassé * * @param int $id_enseignant ID de l'enseignant * @param string $provider Provider * @return bool True si quota OK */ public static function checkQuota($id_enseignant, $provider) { self::init(); $result = self::$db->fetchOne( "SELECT quota_max, quota_used FROM api_keys WHERE id_enseignant = ? AND provider = ?", [$id_enseignant, $provider] ); if (!$result || $result['quota_max'] === null) { return true; // Pas de limite } return $result['quota_used'] < $result['quota_max']; } /** * Tester une connexion API * * @param string $provider Provider à tester * @param string $api_key Clé API * @param string $model Modèle à tester * @return array ['success' => bool, 'message' => string, 'latency_ms' => int] */ public static function testConnection($provider, $api_key, $model = null) { $start = microtime(true); try { switch ($provider) { case 'gemini': $result = self::testGemini($api_key, $model ?? 'gemini-2.0-flash-exp'); break; case 'mistral': $result = self::testMistral($api_key, $model ?? 'ministral-3b-latest'); break; case 'openai': $result = self::testOpenAI($api_key, $model ?? 'gpt-4o-mini'); break; case 'ollama': $result = self::testOllama($model ?? 'ministral-3:3b'); break; default: return ['success' => false, 'message' => 'Provider inconnu']; } $latency = round((microtime(true) - $start) * 1000); $result['latency_ms'] = $latency; return $result; } catch (Exception $e) { return [ 'success' => false, 'message' => 'Erreur: ' . $e->getMessage(), 'latency_ms' => 0 ]; } } /** * Tester connexion Gemini */ private static function testGemini($api_key, $model) { $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$api_key}"; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 10, CURLOPT_POSTFIELDS => json_encode([ 'contents' => [['parts' => [['text' => 'Test']]]] ]) ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code === 200) { return ['success' => true, 'message' => 'Connexion Gemini réussie']; } elseif ($http_code === 400) { return ['success' => false, 'message' => 'Clé API invalide']; } elseif ($http_code === 429) { return ['success' => false, 'message' => 'Quota dépassé']; } else { return ['success' => false, 'message' => "Erreur HTTP $http_code"]; } } /** * Tester connexion Mistral */ private static function testMistral($api_key, $model) { $ch = curl_init('https://api.mistral.ai/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $api_key ], CURLOPT_TIMEOUT => 10, CURLOPT_POSTFIELDS => json_encode([ 'model' => $model, 'messages' => [['role' => 'user', 'content' => 'Test']], 'max_tokens' => 10 ]) ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code === 200) { return ['success' => true, 'message' => 'Connexion Mistral réussie']; } elseif ($http_code === 401) { return ['success' => false, 'message' => 'Clé API invalide']; } else { return ['success' => false, 'message' => "Erreur HTTP $http_code"]; } } /** * Tester connexion OpenAI */ private static function testOpenAI($api_key, $model) { $ch = curl_init('https://api.openai.com/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $api_key ], CURLOPT_TIMEOUT => 10, CURLOPT_POSTFIELDS => json_encode([ 'model' => $model, 'messages' => [['role' => 'user', 'content' => 'Test']], 'max_tokens' => 10 ]) ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code === 200) { return ['success' => true, 'message' => 'Connexion OpenAI réussie']; } elseif ($http_code === 401) { return ['success' => false, 'message' => 'Clé API invalide']; } else { return ['success' => false, 'message' => "Erreur HTTP $http_code"]; } } /** * Tester connexion Ollama (local) */ private static function testOllama($model) { $ch = curl_init('http://localhost:11434/api/generate'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 5, CURLOPT_POSTFIELDS => json_encode([ 'model' => $model, 'prompt' => 'Test', 'stream' => false ]) ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code === 200) { return ['success' => true, 'message' => 'Connexion Ollama réussie']; } else { return ['success' => false, 'message' => 'Ollama non disponible']; } } }