t('settings.general_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'associationLogo' => AssociationBrand::path(), ]); } public static function saveGeneral(): void { self::admin(); self::postOnly(); $values = []; foreach ( [ 'association_name', 'association_address', 'association_postal_code', 'association_city', 'association_phone', 'association_email', 'association_siret', 'association_rna', ] as $key ) { $values[$key] = trim((string) ($_POST[$key] ?? '')); } if ($values['association_name'] === '') { http_response_code(400); echo h(t('settings.association_required')); return; } if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) { http_response_code(400); echo h(t('settings.invalid_email')); return; } $values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15))); $values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10))); $values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')]) ? (string) $_POST['app_language'] : 'fr'; try { $fullAddress = implode( ', ', array_filter([ $values['association_address'], trim($values['association_postal_code'] . ' ' . $values['association_city']), ]), ); if ($fullAddress === '') { $values['association_latitude'] = ''; $values['association_longitude'] = ''; } elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) { $values['association_latitude'] = (string) $point['lat']; $values['association_longitude'] = (string) $point['lng']; } if (isset($_POST['delete_association_logo'])) { AssociationBrand::delete(); } elseif (isset($_FILES['association_logo'])) { AssociationBrand::store($_FILES['association_logo']); } AppSettings::save($values, Auth::id()); AuditService::log( 'settings_saved', '/settings/general/save', 'Paramètres généraux et identité visuelle modifiés', ); header('Location: /settings/general?saved=1'); } catch (Throwable $e) { header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function associationLogo(): void { self::admin(); $path = AssociationBrand::path(); if (!$path) { http_response_code(404); return; } $mime = (new finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream'; header('Content-Type: ' . $mime); header('Content-Length: ' . filesize($path)); header('Cache-Control: private, max-age=3600'); header('X-Content-Type-Options: nosniff'); readfile($path); } public static function users(): void { self::admin(); $users = DB::pdo() ->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC); render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]); } public static function saveUser(): void { self::admin(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); return; } $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $username = trim((string) ($_POST['username'] ?? '')); $display = trim((string) ($_POST['display_name'] ?? '')); $role = (string) ($_POST['role'] ?? 'lecture'); $active = isset($_POST['active']) ? 1 : 0; $password = (string) ($_POST['password'] ?? ''); if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) { http_response_code(400); echo h(t('settings.invalid_data')); return; } if ($id === Auth::id() && (!$active || $role !== 'admin')) { http_response_code(400); echo h(t('settings.keep_admin')); return; } if ($id) { $params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id]; $sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')"; if ($password !== '') { if (strlen($password) < 10) { http_response_code(400); echo h(t('settings.password_short')); return; } $sql .= ',password_hash=:p'; $params[':p'] = password_hash($password, PASSWORD_DEFAULT); } $sql .= ' WHERE id=:id'; $db->prepare($sql)->execute($params); $summary = 'Utilisateur modifié : ' . $display; } else { if (strlen($password) < 10) { http_response_code(400); echo h(t('settings.password_required')); return; } $db->prepare( 'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)', )->execute([ ':u' => $username, ':p' => password_hash($password, PASSWORD_DEFAULT), ':d' => $display, ':r' => $role, ':a' => $active, ]); $id = (int) $db->lastInsertId(); $summary = 'Utilisateur créé : ' . $display; } AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [ 'role' => $role, 'active' => $active, ]); header('Location: /settings/users'); exit(); } public static function permissions(): void { self::admin(); render('settings_permissions.php', [ 'title' => t('settings.permissions_title'), 'roles' => PermissionService::ROLES, 'modules' => PermissionService::MODULES, 'matrix' => PermissionService::matrix(), 'saved' => isset($_GET['saved']), ]); } public static function savePermissions(): void { self::admin(); self::postOnly(); PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id()); AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées'); header('Location: /settings/permissions?saved=1'); exit(); } public static function species(): void { self::admin(); render('settings_species.php', [ 'title' => t('settings.species_title'), 'species' => SpeciesService::all(), 'categories' => SpeciesService::CATEGORIES, 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function rooms(): void { self::admin(); render('settings_rooms.php', [ 'title' => t('rooms.title'), 'rooms' => ShelterRoomService::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveRooms(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $submitted = (array) ($_POST['rooms'] ?? []); try { $db->beginTransaction(); foreach ($submitted as $id => $row) { $id = (int) $id; if (!$id) { continue; } $name = trim((string) ($row['name'] ?? '')); $type = (string) ($row['room_type'] ?? 'collective'); $status = (string) ($row['status_code'] ?? 'refuge'); $color = (string) ($row['color'] ?? '#6c757d'); if ( $name === '' || !in_array($type, ['collective', 'boxes'], true) || !in_array($status, ['refuge', 'soin', 'quarantaine'], true) || !preg_match('/^#[0-9a-fA-F]{6}$/', $color) ) { throw new RuntimeException(t('rooms.invalid')); } $db->prepare( "UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?", )->execute([ $name, $type, $status, $color, isset($row['bulk_validation']) ? 1 : 0, isset($row['active']) ? 1 : 0, max(0, (int) ($row['sort_order'] ?? 100)), $id, ]); self::syncBoxes($db, $id, (string) ($row['boxes'] ?? '')); } $new = (array) ($_POST['new_room'] ?? []); $newName = trim((string) ($new['name'] ?? '')); if ($newName !== '') { $code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName)); if (strlen($code) < 2) { throw new RuntimeException(t('rooms.invalid')); } $type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true) ? (string) $new['room_type'] : 'collective'; $status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true) ? (string) $new['status_code'] : 'refuge'; $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? '')) ? (string) $new['color'] : '#6c757d'; $db->prepare( 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)', )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]); self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? '')); } $db->commit(); AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés'); header('Location: /settings/rooms?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } private static function syncBoxes(PDO $db, int $roomId, string $lines): void { $names = array_values( array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''), ); $existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id'); $existing->execute([$roomId]); $known = $existing->fetchAll(PDO::FETCH_ASSOC); $kept = []; foreach ($names as $index => $name) { $position = ($index + 1) * 10; if (isset($known[$index])) { $id = (int) $known[$index]['id']; $kept[] = $id; $db->prepare( "UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?", )->execute([$name, $position, $id]); continue; } $code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1); $base = $code; $suffix = 2; $exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?'); while (true) { $exists->execute([$roomId, $code]); if (!$exists->fetchColumn()) { break; } $code = $base . '-' . $suffix++; } $db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([ $roomId, $code, $name, $position, ]); $kept[] = (int) $db->lastInsertId(); } foreach ($known as $box) { if (!in_array((int) $box['id'], $kept, true)) { $db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([ (int) $box['id'], ]); } } } public static function saveSpecies(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $name = trim((string) ($_POST['name'] ?? '')); $code = SpeciesService::normalize((string) ($_POST['code'] ?? '')); $category = (string) ($_POST['category'] ?? 'autre'); $icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾'; $color = trim((string) ($_POST['color'] ?? '#6c757d')); $active = isset($_POST['active']) ? 1 : 0; $sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100))); if ( $name === '' || !preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) || !isset(SpeciesService::CATEGORIES[$category]) || !preg_match('/^#[0-9a-fA-F]{6}$/', $color) ) { header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid'))); exit(); } try { if ($id > 0) { $old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id'); $old->execute([':id' => $id]); $oldRow = $old->fetch(PDO::FETCH_ASSOC); if (!$oldRow) { throw new RuntimeException(t('settings.species_not_found')); } $oldCode = (string) $oldRow['code']; if ((int) $oldRow['is_system'] === 1) { $code = $oldCode; } $db->beginTransaction(); $db->prepare( 'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id', )->execute([ ':code' => $code, ':name' => $name, ':category' => $category, ':icon' => $icon, ':color' => $color, ':active' => $active, ':sort' => $sort, ':id' => $id, ]); if ($oldCode !== $code) { $db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([ ':new' => $code, ':old' => $oldCode, ]); } $db->commit(); } else { $db->prepare( 'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)', )->execute([ ':code' => $code, ':name' => $name, ':category' => $category, ':icon' => $icon, ':color' => $color, ':active' => $active, ':sort' => $sort, ]); } AuditService::log( 'species_saved', '/settings/species/save', 'Espèce enregistrée : ' . $name, 'species', $id ?: ((int) $db->lastInsertId()), ); header('Location: /settings/species?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function pricing(): void { self::admin(); $db = DB::pdo(); $clinics = $db ->query( "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", ) ->fetchAll(PDO::FETCH_ASSOC); $discounts = []; foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) { $discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r; } render('settings_pricing.php', [ 'title' => t('pricing.title'), 'tariffs' => PricingService::tariffs(), 'clinics' => $clinics, 'discounts' => $discounts, 'categories' => PricingService::CATEGORIES, 'groups' => PricingService::GROUPS, 'medications' => $db ->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'vaccines' => $db ->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'dewormers' => $db ->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveTariff(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); $label = trim((string) ($_POST['label'] ?? '')); $category = (string) ($_POST['category'] ?? 'other'); $group = (string) ($_POST['discount_group'] ?? 'act'); $unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte'; $amount = PricingService::cents((string) ($_POST['amount'] ?? '0')); $active = isset($_POST['active']) ? 1 : 0; $eligible = $db->prepare( "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", ); $eligible->execute([':id' => $clinic]); if ( !$clinic || !$eligible->fetchColumn() || $label === '' || !isset(PricingService::CATEGORIES[$category]) || !isset(PricingService::GROUPS[$group]) ) { header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure'))); exit(); } $params = [ $clinic, mb_substr($label, 0, 180), $category, $group, mb_substr($unit, 0, 50), (int) ($_POST['medication_id'] ?? 0) ?: null, (int) ($_POST['vaccine_id'] ?? 0) ?: null, (int) ($_POST['dewormer_id'] ?? 0) ?: null, $amount, $active, trim((string) ($_POST['notes'] ?? '')) ?: null, ]; try { if ($id) { $params[] = $id; $db->prepare( "UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?", )->execute($params); } else { $db->prepare( 'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)', )->execute($params); $id = (int) $db->lastInsertId(); } AuditService::log( 'tariff_saved', '/settings/pricing/tariff', 'Tarif enregistré : ' . $label, 'clinic_tariff', $id, ); header('Location: /settings/pricing?saved=1'); } catch (Throwable $e) { header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveDiscount(): void { self::admin(); self::postOnly(); $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); $submitted = (array) ($_POST['discounts'] ?? []); $db = DB::pdo(); $eligible = $db->prepare( "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", ); $eligible->execute([':id' => $clinic]); if (!$clinic || !$eligible->fetchColumn()) { http_response_code(400); echo h(t('pricing.invalid_discount_structure')); return; } $stmt = $db->prepare( 'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent', ); $saved = []; try { $db->beginTransaction(); foreach (PricingService::GROUPS as $group => $label) { if ($group === 'none' || !array_key_exists($group, $submitted)) { continue; } $percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group]))); $stmt->execute([$clinic, $group, $label, $percent]); $saved[$group] = $percent; } $db->commit(); AuditService::log( 'discount_saved', '/settings/pricing/discount', 'Remises tarifaires modifiées', 'clinic', $clinic, $saved, ); header('Location: /settings/pricing?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveAdoptionFees(): void { self::admin(); self::postOnly(); AppSettings::save( [ 'adoption_fee_sterilized' => (string) max( 0, min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)), ), 'adoption_fee_unsterilized' => (string) max( 0, min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)), ), ], Auth::id(), ); AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés'); header('Location: /settings/pricing?saved=1'); exit(); } public static function labReferences(): void { self::admin(); $rows = LabReferenceService::all(); $groups = []; foreach ($rows as $row) { $groups[$row['analyzer_name']][$row['species_code']][] = $row; } render('settings_lab_references.php', [ 'title' => t('lab_reference.title'), 'groups' => $groups, 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveLabReferences(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $input = (array) ($_POST['ranges'] ?? []); $stmt = $db->prepare( "UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?", ); try { $db->beginTransaction(); foreach ($input as $id => $row) { $id = (int) $id; $name = trim((string) ($row['name'] ?? '')); $unit = trim((string) ($row['unit'] ?? '')); $min = self::nullableNumber($row['min'] ?? ''); $max = self::nullableNumber($row['max'] ?? ''); if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) { throw new RuntimeException(t('lab_reference.invalid')); } $stmt->execute([ mb_substr($name, 0, 120), mb_substr($unit, 0, 30), $min, $max, mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null, isset($row['active']) ? 1 : 0, Auth::id(), $id, ]); } $db->commit(); AuditService::log( 'lab_references_saved', '/settings/lab-references/save', 'Intervalles de référence biologiques modifiés', ); header('Location: /settings/lab-references?saved=1'); } catch (RuntimeException $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function publicSite(): void { self::admin(); render('settings_public_site.php', [ 'title' => t('public_site.settings_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function health(): void { self::admin(); render('settings_health.php', [ 'title' => t('health.page_title'), 'report' => SystemHealthService::report(), 'installed' => isset($_GET['installed']), ]); } public static function notifications(): void { self::admin(); render('settings_notifications.php', [ 'title' => t('notification.page_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'sent' => (string) ($_GET['sent'] ?? ''), ]); } public static function saveNotifications(): void { self::admin(); self::postOnly(); $email = trim((string) ($_POST['notification_email_recipient'] ?? '')); if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { header('Location: /settings/notifications?sent=invalid'); exit(); } $frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true) ? (string) $_POST['notification_email_frequency'] : 'weekly'; AppSettings::save( [ 'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0', 'notification_email_frequency' => $frequency, 'notification_email_recipient' => $email, ], Auth::id(), ); AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée'); header('Location: /settings/notifications?saved=1'); exit(); } public static function sendNotifications(): void { self::admin(); self::postOnly(); $result = NotificationService::send(true); AuditService::log( 'notification_digest', '/settings/notifications/send', 'Envoi manuel du résumé : ' . $result['reason'], ); header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason'])); exit(); } public static function savePublicSite(): void { self::admin(); self::postOnly(); $theme = (string) ($_POST['public_site_theme'] ?? 'warm'); if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) { $theme = 'warm'; } $routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); if (!in_array($routing, ['integrated', 'root'], true)) { $routing = 'integrated'; } $values = [ 'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0', 'public_site_theme' => $theme, 'public_site_routing' => $routing, 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', ]; foreach ( [ 'public_site_home_title' => 150, 'public_site_home_text' => 5000, 'public_site_about_title' => 150, 'public_site_about_text' => 10000, 'public_site_donation_title' => 150, 'public_site_donation_text' => 10000, 'public_site_contact_text' => 5000, 'public_site_legal_text' => 15000, 'public_site_privacy_text' => 15000, ] as $key => $max ) { $values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max); } foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $key) { $url = trim((string) ($_POST[$key] ?? '')); $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) { header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url'))); exit(); } $values[$key] = $url; } AppSettings::save($values, Auth::id()); AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); header('Location: /settings/public-site?saved=1'); exit(); } public static function audit(): void { self::admin(); $rows = DB::pdo() ->query( 'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500', ) ->fetchAll(PDO::FETCH_ASSOC); render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]); } public static function dataTools(): void { self::admin(); $db = DB::pdo(); render('settings_data.php', [ 'title' => t('data.title'), 'counts' => [ 'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(), 'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(), ], 'status' => (string) ($_GET['status'] ?? ''), 'backup' => (string) ($_GET['backup'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function resetData(): void { self::dataOperation(false); } public static function loadDemoData(): void { self::dataOperation(true); } private static function dataOperation(bool $demo): void { self::admin(); self::postOnly(); $expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase'); if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) { header( 'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])), ); exit(); } $db = DB::pdo(); try { $backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset'); $db->beginTransaction(); $removed = DemoDataService::reset($db); $demo ? DemoDataService::seed($db, Auth::id()) : null; $db->commit(); DemoDataService::clearFiles(); if ($demo) { DemoDataService::installFiles($db); } AuditService::log( $demo ? 'demo_data_loaded' : 'business_data_reset', '/settings/data', $demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro', null, null, [ 'removed_animals' => $removed['animals'], 'removed_contacts' => $removed['contacts'], 'backup' => $backup['name'], ], ); header( 'Location: /settings/data?status=' . ($demo ? 'demo' : 'reset') . '&backup=' . rawurlencode($backup['name']), ); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function backups(): void { self::admin(); render('settings_backups.php', [ 'title' => t('backups.title'), 'backups' => BackupService::list(), 'status' => (string) ($_GET['status'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function createBackup(): void { self::admin(); self::postOnly(); try { $backup = BackupService::create(); AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']); header('Location: /settings/backups?status=created'); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveBackupSchedule(): void { self::admin(); self::postOnly(); $frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily'); if (!in_array($frequency, ['daily', 'weekly'], true)) { $frequency = 'daily'; } AppSettings::save( [ 'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0', 'backup_schedule_frequency' => $frequency, ], Auth::id(), ); AuditService::log( 'backup_schedule_saved', '/settings/backups/schedule', 'Planification des sauvegardes modifiée', ); header('Location: /settings/backups?status=schedule'); exit(); } public static function verifyBackup(): void { self::admin(); self::postOnly(); try { $result = BackupService::verify((string) ($_POST['file'] ?? '')); AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id()); AuditService::log( 'backup_verified', '/settings/backups/verify', 'Sauvegarde vérifiée : ' . $result['name'], ); header('Location: /settings/backups?status=verified'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function downloadBackup(): void { self::admin(); $path = BackupService::path((string) ($_GET['file'] ?? '')); if (!$path) { http_response_code(404); echo h(t('backups.not_found')); return; } PrivacyService::logAccess('backup', null, null, basename($path)); AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . basename($path) . '"'); header('Content-Length: ' . filesize($path)); header('Cache-Control: private,no-store'); header('X-Content-Type-Options: nosniff'); readfile($path); } public static function deleteBackup(): void { self::admin(); self::postOnly(); $name = (string) ($_POST['file'] ?? ''); try { BackupService::delete($name); AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name); header('Location: /settings/backups?status=deleted'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function restoreBackup(): void { self::admin(); self::postOnly(); $upload = $_FILES['backup'] ?? null; if ( !is_array($upload) || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) $upload['tmp_name']) ) { header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file'))); exit(); } if ((int) ($upload['size'] ?? 0) > 1_000_000_000) { header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large'))); exit(); } try { BackupService::restore((string) $upload['tmp_name']); Auth::logout(); header('Location: /login?restored=1'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } private static function postOnly(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); header('Allow: POST'); exit(); } } private static function nullableNumber(mixed $value): ?float { $value = str_replace(',', '.', trim((string) $value)); if ($value === '') { return null; } if (!is_numeric($value)) { throw new RuntimeException(t('lab_reference.invalid')); } return (float) $value; } private static function admin(): void { if (!Auth::is('admin')) { http_response_code(403); echo h(t('settings.admin_only')); exit(); } } }