diff --git a/CHANGELOG.md b/CHANGELOG.md index 5072944..a2d3626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.4] — 2026-09-07 + +### Correctifs et site public + +- rétablissement des déplacements depuis la configuration des tournées et réparation des droits des photos publiques ; +- correction de l’affichage du menu « Plus » dans les fiches animales ; +- filtres cliquables depuis les statistiques pour les espèces, sexes, couleurs, races et types de poil ; +- ajout d’espèces aux références biologiques par copie d’un référentiel existant ; +- évolution du site public : nom distinct, thème CSS personnalisé, mise en forme guidée et réseaux sociaux supplémentaires ; +- lien officiel Globinours dans le pied de page et police emoji locale homogène sur Windows et Linux ; +- diagnostics I-CAD plus précis en cas d’indisponibilité ou de changement du service distant. + ## [1.0.0-rc.3] — 2026-09-03 ### Stabilisation @@ -43,7 +55,7 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s - annuaire, tournées, statistiques, registre administratif et exports ; - comptes, rôles, permissions, audit et sauvegardes ; - import ASM3 en simulation puis transaction protégée ; -- interface responsive, PWA, mini-site public facultatif et internationalisation FR/EN. +- interface responsive, PWA, site public facultatif et internationalisation FR/EN. [1.0.0]: https://nnsprod.com/git/oversu/Globinours [0.1.0]: https://nnsprod.com/git/oversu/Globinours diff --git a/HISTORIQUE.md b/HISTORIQUE.md index b309e50..cab7394 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -2,7 +2,7 @@ Globinours est né d’un besoin de terrain : réunir dans une application légère les informations qu’un refuge utilise chaque jour, sans imposer une infrastructure complexe. Le projet s’est construit autour de PHP et SQLite afin de rester facile à installer, sauvegarder et déplacer. -Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un mini-site public séparant strictement les contenus publiés des informations internes. +Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un site public séparant strictement les contenus publiés des informations internes. La sécurité et la continuité ont progressivement rejoint le cœur du projet : authentification, permissions, journal d’audit, médias privés hors de la racine web, sauvegardes vérifiées, restauration contrôlée, protection CSRF, limitation des connexions et paramètres adaptés aux reverse proxies de confiance. @@ -21,3 +21,7 @@ Recherche transversale respectant les permissions et poursuite du découpage du ## `1.0.0-rc.3` — Gel fonctionnel Séparation des ressources visuelles et des routes, correction de l’installation vierge, jeu de démonstration enrichi et harmonisation de la présentation avant le gel fonctionnel de la version 1.0. + +## `1.0.0-rc.4` — Retours de terrain + +Correction des déplacements et des médias publics, amélioration du site public, ajout de filtres depuis les statistiques et extension des références biologiques à toutes les espèces configurées. diff --git a/README.md b/README.md index f374211..b13e51c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ L’application est développée à partir de besoins rencontrés sur le terrain - factures vétérinaires, règlements, justificatifs et statistiques de coûts ; - registre des entrées et sorties, exports CSV et dossiers de subvention ; - recherche globale avec contrôle des résultats selon le rôle ; -- mini-site public facultatif pour les animaux proposés à l’adoption ; +- site public facultatif pour les animaux proposés à l’adoption ; - import progressif depuis Animal Shelter Manager 3 (ASM3) ; - sauvegarde et restauration depuis l’interface d’administration ; - interface française et anglaise. diff --git a/VERSION b/VERSION index 3c029dd..d17f065 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.3 +1.0.0-rc.4 diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php index afed308..917f5b1 100644 --- a/app/Controllers/AnimalBrowseActions.php +++ b/app/Controllers/AnimalBrowseActions.php @@ -13,6 +13,8 @@ trait AnimalBrowseActions $quality = trim((string) ($_GET['quality'] ?? '')); $availability = trim((string) ($_GET['availability'] ?? '')); $species = SpeciesService::normalize((string) ($_GET['species'] ?? '')); + $attribute = (string) ($_GET['attribute'] ?? ''); + $attributeValue = trim((string) ($_GET['value'] ?? '')); $sort = (string) ($_GET['sort'] ?? 'updated_at'); $dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC'; $requestedView = (string) ($_GET['view'] ?? ''); @@ -59,6 +61,20 @@ trait AnimalBrowseActions } else { $species = ''; } + $attributeColumns = [ + 'color' => 'a.color', + 'breed' => 'a.breed', + 'hair_type' => 'a.hair_type', + 'sex' => 'a.sex', + 'status' => 'a.status', + ]; + if (isset($attributeColumns[$attribute]) && $attributeValue !== '') { + $where[] = 'lower(trim(' . $attributeColumns[$attribute] . '))=lower(trim(:attribute_value))'; + $params[':attribute_value'] = $attributeValue; + } else { + $attribute = ''; + $attributeValue = ''; + } if ($q !== '') { $where[] = '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)'; @@ -407,6 +423,8 @@ trait AnimalBrowseActions 'availability' => $availability, 'viewMode' => $viewMode, 'species' => $species, + 'attribute' => $attribute, + 'attributeValue' => $attributeValue, 'speciesOptions' => SpeciesService::all(false), ]); } diff --git a/app/Controllers/AnimalsController.php b/app/Controllers/AnimalsController.php index 87ed9e8..e5407d6 100644 --- a/app/Controllers/AnimalsController.php +++ b/app/Controllers/AnimalsController.php @@ -486,7 +486,7 @@ final class AnimalsController try { $svc->refreshByChip($id, $chipId); } catch (Throwable $e) { - // Le cache conserve l'état d'erreur fourni par le service. + error_log('I-CAD refresh failed for animal ' . $id . ': ' . $e->getMessage()); } $cached = $svc->getCachedByAnimal($id); $stale = $svc->isStale($cached, 86400); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index f81d89b..6b5d053 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -592,8 +592,26 @@ final class SettingsController 'groups' => $groups, 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), + 'speciesOptions' => SpeciesService::all(true), ]); } + public static function addLabReferenceSpecies(): void + { + self::admin(); + self::postOnly(); + try { + $count = LabReferenceService::copySpecies( + (string) ($_POST['source_species'] ?? ''), + (string) ($_POST['target_species'] ?? ''), + Auth::id(), + ); + AuditService::log('lab_reference_species_added', '/settings/lab-references/species', "$count références biologiques ajoutées"); + header('Location: /settings/lab-references?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } public static function saveLabReferences(): void { self::admin(); @@ -716,7 +734,7 @@ final class SettingsController self::admin(); self::postOnly(); $theme = (string) ($_POST['public_site_theme'] ?? 'warm'); - if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) { + if (!in_array($theme, ['warm', 'nature', 'minimal', 'custom'], true)) { $theme = 'warm'; } $routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); @@ -732,6 +750,7 @@ final class SettingsController foreach ( [ 'public_site_home_title' => 150, + 'public_site_name' => 150, 'public_site_home_text' => 5000, 'public_site_about_title' => 150, 'public_site_about_text' => 10000, @@ -745,7 +764,13 @@ final class SettingsController ) { $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) { + $customCss = trim((string) ($_POST['public_site_custom_css'] ?? '')); + if (strlen($customCss) > 30000 || preg_match('/(?:<\/style|@import|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|url\s*\()/i', $customCss)) { + header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_css'))); + exit(); + } + $values['public_site_custom_css'] = $customCss; + foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url', 'public_site_mastodon_url', 'public_site_youtube_url', 'public_site_tiktok_url', 'public_site_bluesky_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')) { diff --git a/app/Routes/web.php b/app/Routes/web.php index dfd5a24..990eee8 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -341,6 +341,9 @@ switch ($path) { case '/settings/lab-references/save': SettingsController::saveLabReferences(); break; + case '/settings/lab-references/species': + SettingsController::addLabReferenceSpecies(); + break; case '/settings/public-site': SettingsController::publicSite(); break; diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php index 33defc9..ee09405 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -22,6 +22,8 @@ final class AppSettings 'app_language' => 'fr', 'public_site_enabled' => '0', 'public_site_theme' => 'warm', + 'public_site_name' => '', + 'public_site_custom_css' => '', 'public_site_routing' => 'integrated', 'public_site_home_title' => 'Ils attendent une famille', 'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.', @@ -35,6 +37,10 @@ final class AppSettings 'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.', 'public_site_facebook_url' => '', 'public_site_instagram_url' => '', + 'public_site_mastodon_url' => '', + 'public_site_youtube_url' => '', + 'public_site_tiktok_url' => '', + 'public_site_bluesky_url' => '', 'notification_email_enabled' => '0', 'notification_email_frequency' => 'weekly', 'notification_email_recipient' => '', diff --git a/app/Services/IcadService.php b/app/Services/IcadService.php index ded5952..ef381d3 100644 --- a/app/Services/IcadService.php +++ b/app/Services/IcadService.php @@ -87,11 +87,12 @@ final class IcadService throw new RuntimeException(t('service.icad.credentials_missing')); } - $cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie'; + $cookie = sys_get_temp_dir() . '/globinours_icad_' . bin2hex(random_bytes(6)) . '.cookie'; try { // 1) GET HOME => token _token du formulaire login ACTEUR $r1 = $this->req('https://www.i-cad.fr/', $cookie); + $this->assertSuccess($r1, t('service.icad.home_unavailable')); $token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé @@ -109,16 +110,19 @@ final class IcadService 'Origin: https://www.i-cad.fr', 'Referer: https://www.i-cad.fr/', ]); + $this->assertSuccess($r2, t('service.icad.login_failed')); // 3) Vérif session $r2b = $this->req('https://www.i-cad.fr/pro', $cookie); - if (stripos($r2b['body'], 'Se connecter') !== false) { + $this->assertSuccess($r2b, t('service.icad.login_failed')); + if (stripos($r2b['body'], 'Se connecter') !== false || str_contains((string)$r2b['effective_url'], '/login')) { throw new RuntimeException(t('service.icad.login_failed')); } // 4) GET recherche. I-CAD a renommé le formulaire // search_animal en animal_search en 2026 : on accepte les deux. $r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie); + $this->assertSuccess($r3, t('service.icad.search_unavailable')); [$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']); // 5) POST recherche @@ -136,6 +140,7 @@ final class IcadService 'Origin: https://www.i-cad.fr', 'Referer: https://www.i-cad.fr/identification/recherche', ]); + $this->assertSuccess($r4, t('service.icad.search_unavailable')); $animalPath = $this->extractAnimalPath($r4); if (!$animalPath) { @@ -144,6 +149,7 @@ final class IcadService // 6) GET fiche animal $r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie); + $this->assertSuccess($r5, t('service.icad.record_unavailable')); $data = $this->parseAnimalHtml($r5['body'], $animalPath); $this->saveCache($animalId, $chipId, 'ok', null, $data); @@ -158,6 +164,14 @@ final class IcadService // ========= HTTP ========= + private function assertSuccess(array $response, string $message): void + { + $status = (int) ($response['status'] ?? 0); + if ($status < 200 || $status >= 400) { + throw new RuntimeException($message . ' (HTTP ' . $status . ')'); + } + } + private function req( string $url, string $cookieFile, diff --git a/app/Services/LabReferenceService.php b/app/Services/LabReferenceService.php index 99fb2ca..b43fa06 100644 --- a/app/Services/LabReferenceService.php +++ b/app/Services/LabReferenceService.php @@ -33,4 +33,23 @@ final class LabReferenceService } return $out; } + + public static function copySpecies(string $source, string $target, ?int $userId): int + { + $source = SpeciesService::normalize($source); + $target = SpeciesService::normalize($target); + if ($source === $target || !SpeciesService::exists($source, false) || !SpeciesService::exists($target, false)) { + throw new RuntimeException(t('lab_reference.invalid_species')); + } + $stmt = DB::pdo()->prepare( + "INSERT OR IGNORE INTO lab_reference_ranges(analyzer_code,analyzer_name,species_code,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,updated_by,updated_at) + SELECT analyzer_code,analyzer_name,:target,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,:user,datetime('now') + FROM lab_reference_ranges WHERE species_code=:source", + ); + $stmt->execute([':source' => $source, ':target' => $target, ':user' => $userId]); + if ($stmt->rowCount() === 0) { + throw new RuntimeException(t('lab_reference.nothing_copied')); + } + return $stmt->rowCount(); + } } diff --git a/app/Services/PrivateMediaService.php b/app/Services/PrivateMediaService.php index b5b7387..2ed2bc6 100644 --- a/app/Services/PrivateMediaService.php +++ b/app/Services/PrivateMediaService.php @@ -21,12 +21,13 @@ final class PrivateMediaService return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId; } - public static function ensure(string $dir): void + public static function ensure(string $dir, bool $public = false): void { - if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + $mode = $public ? 0755 : 0700; + if (!is_dir($dir) && !mkdir($dir, $mode, true) && !is_dir($dir)) { throw new RuntimeException(t('service.image.directory_failed')); } - @chmod($dir, 0700); + @chmod($dir, $mode); } public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void @@ -45,7 +46,7 @@ final class PrivateMediaService } throw new RuntimeException(t('service.private_media.not_found')); } - self::ensure($to); + self::ensure($to, $toPublic); if (!rename($source, $target)) { throw new RuntimeException(t('service.private_media.move_failed')); } @@ -55,6 +56,19 @@ final class PrivateMediaService public static function migrateLegacy(): void { $db = DB::pdo(); + $public = $db + ->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=1') + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($public as $row) { + $dir = self::publicDir((int) $row['animal_id']); + if (is_dir($dir)) { + @chmod($dir, 0755); + } + $file = $dir . '/' . basename((string) $row['filename']); + if (is_file($file)) { + @chmod($file, 0644); + } + } $private = $db ->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0') ->fetchAll(PDO::FETCH_ASSOC); diff --git a/app/Services/PublicContentFormatter.php b/app/Services/PublicContentFormatter.php new file mode 100644 index 0000000..6c04a42 --- /dev/null +++ b/app/Services/PublicContentFormatter.php @@ -0,0 +1,67 @@ +'; + $listOpen = false; + } + continue; + } + if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) { + if (!$listOpen) { + $html[] = ''; + $listOpen = false; + } + if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) { + $level = strlen($match[1]) + 1; + $html[] = '' . self::inline($match[2]) . ''; + continue; + } + $html[] = '

' . self::inline($line) . '

'; + } + if ($listOpen) { + $html[] = ''; + } + return implode("\n", $html); + } + + private static function inline(string $text): string + { + $links = []; + $text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string { + if (!filter_var($match[2], FILTER_VALIDATE_URL)) { + return $match[0]; + } + $token = "\x1A" . count($links) . "\x1A"; + $links[] = '' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + return $token; + }, $text) ?? $text; + $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = preg_replace('/\*\*(.+?)\*\*/u', '$1', $text) ?? $text; + $text = preg_replace('/(?$1', $text) ?? $text; + foreach ($links as $index => $link) { + $text = str_replace("\x1A" . $index . "\x1A", $link, $text); + } + return $text; + } +} diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 3dc79d0..724081d 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -607,9 +607,14 @@ final class StatisticsService "SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label", $p, ); + $hairTypes = $rows( + $entered . + "SELECT COALESCE(NULLIF(TRIM(a.hair_type),''),'Non renseigné') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label", + $p, + ); $species = $rows( $entered . - "SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + "SELECT CASE lower(a.species) WHEN 'cat' THEN 'chat' WHEN 'dog' THEN 'chien' ELSE COALESCE(NULLIF(TRIM(lower(a.species)),''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", $p, ); $intakeTypes = $rows( @@ -680,6 +685,7 @@ final class StatisticsService return [ 'colors' => $colors, 'breeds' => $breeds, + 'hairTypes' => $hairTypes, 'species' => $species, 'intakeTypes' => $intakeTypes, 'intakeReasons' => $intakeReasons, diff --git a/app/Views/animal_show.php b/app/Views/animal_show.php index 879eb07..eb1867f 100644 --- a/app/Views/animal_show.php +++ b/app/Views/animal_show.php @@ -94,6 +94,22 @@ switch ($animal['sex']) { text-decoration: none; } + .animal-tabs { + overflow: visible; + position: relative; + z-index: 20; + } + + .animal-tabs .dropdown-menu { + z-index: 2050; + } + + @media (max-width: 767.98px) { + .animal-tabs { + flex-wrap: wrap; + } + } + @@ -429,7 +445,7 @@ $registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registra
-