Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
final class Asm3ImportController
{
public static function index(): void
{
self::admin();
$jobs = DB::pdo()
->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();
}
}
}