query( 'SELECT j.*,COALESCE(u.display_name,u.username) actor FROM asm3_import_jobs j LEFT JOIN users u ON u.id=j.created_by ORDER BY j.id DESC LIMIT 20', ) ->fetchAll(PDO::FETCH_ASSOC); render('settings_asm3.php', [ 'title' => t('asm3.title'), 'jobs' => $jobs, 'error' => (string) ($_GET['error'] ?? ''), 'success' => (string) ($_GET['success'] ?? ''), ]); } public static function upload(): void { self::admin(); $f = $_FILES['dump'] ?? null; if (!is_array($f)) { self::fail( self::postTooLarge() ? t('asm3.post_too_large', ['limit' => ini_get('post_max_size')]) : t('asm3.select_dump'), ); } $uploadError = (int) ($f['error'] ?? UPLOAD_ERR_NO_FILE); if ($uploadError !== UPLOAD_ERR_OK) { self::fail(self::uploadErrorMessage($uploadError)); } if (!is_uploaded_file((string) $f['tmp_name'])) { self::fail(t('asm3.upload_unverified')); } if ((int) $f['size'] > 250_000_000) { self::fail(t('asm3.over_250')); } $name = (string) $f['name']; $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($extension, ['sql', 'pgsql'], true)) { self::fail(t('asm3.extension')); } $dir = dirname(__DIR__, 2) . '/storage/asm3-imports'; if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { self::fail(t('asm3.storage_unavailable')); } $stored = bin2hex(random_bytes(16)) . '.' . $extension; $path = $dir . '/' . $stored; if (!move_uploaded_file((string) $f['tmp_name'], $path)) { self::fail(t('asm3.storage_failed')); } chmod($path, 0600); try { $analysis = Asm3Analyzer::analyze($path); if (($analysis['tables']['animal'] ?? 0) < 1) { throw new RuntimeException(t('asm3.no_animal')); } $animals = Asm3MigrationPlanner::plan($path, DB::pdo()); $contacts = Asm3ContactPlanner::plan($path, DB::pdo()); $report = [ 'analysis' => $analysis, 'animals' => $animals['summary'], 'contacts' => $contacts['summary'], 'movements' => Asm3MovementImporter::run($path, DB::pdo(), false)['summary'], 'clinical' => Asm3ClinicalImporter::run($path, DB::pdo(), false), 'litters' => Asm3LitterImporter::run($path, DB::pdo(), false), 'media' => [ 'metadata' => $analysis['tables']['media'] ?? 0, 'importable' => 0, 'reason' => 'Le dump SQL ne contient pas les fichiers binaires ASM3.', ], ]; $db = DB::pdo(); $db->prepare( 'INSERT INTO asm3_import_jobs(original_name,stored_name,source_sha256,analysis_json,created_by) VALUES(:original,:stored,:hash,:analysis,:user)', )->execute([ ':original' => basename($name), ':stored' => $stored, ':hash' => hash_file('sha256', $path), ':analysis' => json_encode($report, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ':user' => Auth::id(), ]); AuditService::log('asm3_analyzed', '/settings/asm3', 'Dump ASM3 analysé : ' . basename($name)); header('Location: /settings/asm3?success=analyzed'); exit(); } catch (Throwable $e) { @unlink($path); self::fail($e->getMessage()); } } public static function apply(): void { self::admin(); $id = (int) ($_POST['id'] ?? 0); $mode = ($_POST['mode'] ?? 'merge') === 'replace' ? 'replace' : 'merge'; $expected = t($mode === 'replace' ? 'asm3.confirm_replace_phrase' : 'asm3.confirm_import_phrase'); if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) { self::fail(t('asm3.confirm_required', ['phrase' => $expected])); } $db = DB::pdo(); $q = $db->prepare("SELECT * FROM asm3_import_jobs WHERE id=:id AND status='analyzed'"); $q->execute([':id' => $id]); $job = $q->fetch(PDO::FETCH_ASSOC); $q->closeCursor(); unset($q); if (!$job) { self::fail(t('asm3.analysis_unavailable')); } $path = dirname(__DIR__, 2) . '/storage/asm3-imports/' . basename($job['stored_name']); if (!is_file($path) || !hash_equals($job['source_sha256'], hash_file('sha256', $path))) { self::fail(t('asm3.dump_altered')); } try { $backup = BackupService::create('pre-asm3-import'); $db->beginTransaction(); $removed = $mode === 'replace' ? Asm3ResetService::clear($db) : null; $results = [ 'mode' => $mode, 'backup' => $backup['name'], 'removed' => $removed, 'animals' => Asm3AnimalImporter::run($path, $db, true, false), 'contacts' => Asm3ContactImporter::run($path, $db, true, false), 'movements' => Asm3MovementImporter::run($path, $db, true, false), 'clinical' => Asm3ClinicalImporter::run($path, $db, true, false), 'litters' => Asm3LitterImporter::run($path, $db, true, false), ]; $db->prepare( "UPDATE asm3_import_jobs SET status='completed',result_json=:result,completed_at=datetime('now') WHERE id=:id", )->execute([ ':result' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ':id' => $id, ]); $db->commit(); if ($mode === 'replace') { Asm3ResetService::clearMedia(); } AuditService::log( 'asm3_imported', '/settings/asm3/apply', 'Migration ASM3 terminée en mode ' . $mode . ' depuis ' . $job['original_name'], ); header('Location: /settings/asm3?success=imported'); exit(); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } $db->prepare( "UPDATE asm3_import_jobs SET status='failed',result_json=:result,completed_at=datetime('now') WHERE id=:id", )->execute([ ':result' => json_encode(['error' => $e->getMessage(), 'mode' => $mode], JSON_UNESCAPED_UNICODE), ':id' => $id, ]); self::fail(t('asm3.import_failed', ['error' => $e->getMessage()])); } } private static function uploadErrorMessage(int $error): string { return match ($error) { UPLOAD_ERR_INI_SIZE => t('asm3.upload_ini', ['limit' => ini_get('upload_max_filesize')]), UPLOAD_ERR_FORM_SIZE => t('asm3.upload_form'), UPLOAD_ERR_PARTIAL => t('asm3.upload_partial'), UPLOAD_ERR_NO_FILE => t('asm3.select_dump'), UPLOAD_ERR_NO_TMP_DIR => t('asm3.tmp_unavailable'), UPLOAD_ERR_CANT_WRITE => t('asm3.write_failed'), UPLOAD_ERR_EXTENSION => t('asm3.extension_stopped'), default => t('asm3.upload_failed', ['code' => $error]), }; } private static function postTooLarge(): bool { $length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); $limit = self::iniBytes((string) ini_get('post_max_size')); return $length > 0 && $limit > 0 && $length > $limit; } private static function iniBytes(string $value): int { $value = trim($value); if ($value === '') { return 0; } $number = (float) $value; return (int) round( $number * match (strtolower(substr($value, -1))) { 'g' => 1024 ** 3, 'm' => 1024 ** 2, 'k' => 1024, default => 1, }, ); } private static function fail(string $message): never { header('Location: /settings/asm3?error=' . rawurlencode($message)); exit(); } private static function admin(): void { if (!Auth::is('admin')) { http_response_code(403); exit(); } } }