fix: fiabiliser les portées et médias publics
This commit is contained in:
parent
2029849f18
commit
4493357590
11 changed files with 121 additions and 6 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,6 +2,16 @@
|
||||||
|
|
||||||
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/).
|
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.24] — 2026-09-15
|
||||||
|
|
||||||
|
### Permissions et bases historiques
|
||||||
|
|
||||||
|
- correction de la routine de sécurité qui empêchait périodiquement Nginx de traverser le dossier de données YunoHost ;
|
||||||
|
- réparation quotidienne des droits des images publiques sans exposer la base ni les documents privés ;
|
||||||
|
- droits immédiatement adaptés lors de la création d’un dossier d’images publiques ;
|
||||||
|
- normalisation des fichiers servis par le Web après une mise à jour autonome ou un retour arrière.
|
||||||
|
- harmonisation des deux anciens schémas de l’historique afin de rétablir la création des portées sur les bases historiques.
|
||||||
|
|
||||||
## [1.0.0-rc.23] — 2026-09-15
|
## [1.0.0-rc.23] — 2026-09-15
|
||||||
|
|
||||||
### Création des portées
|
### Création des portées
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
|
||||||
|
|
||||||
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
|
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
|
||||||
|
|
||||||
|
## `1.0.0-rc.24` — Permissions durables
|
||||||
|
|
||||||
|
Séparation plus stricte entre les données privées et les médias publics afin que les réparations de sécurité et les mises à jour conservent l’accès de Nginx aux images et aux ressources de l’interface. Les deux structures d’historique rencontrées sur les installations anciennes sont également réunifiées pour sécuriser les portées et les autres écritures métier.
|
||||||
|
|
||||||
## `1.0.0-rc.1` — Première stabilisation
|
## `1.0.0-rc.1` — Première stabilisation
|
||||||
|
|
||||||
Nettoyage du projet, séparation des principales vues animales, découpage du contrôleur et validation initiale des parcours essentiels.
|
Nettoyage du projet, séparation des principales vues animales, découpage du contrôleur et validation initiale des parcours essentiels.
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
|
||||||
|
|
||||||
## État du projet
|
## État du projet
|
||||||
|
|
||||||
La candidate actuelle est `1.0.0-rc.23`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
La candidate actuelle est `1.0.0-rc.24`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
||||||
|
|
||||||
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.0-rc.23
|
1.0.0-rc.24
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ final class ImageService
|
||||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||||
throw new RuntimeException(t('service.image.directory_failed'));
|
throw new RuntimeException(t('service.image.directory_failed'));
|
||||||
}
|
}
|
||||||
|
self::preparePublicDirectory($directory);
|
||||||
$prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo';
|
$prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo';
|
||||||
$useWebp = $profile !== 'logo';
|
$useWebp = $profile !== 'logo';
|
||||||
$extension = $useWebp ? 'webp' : $source['extension'];
|
$extension = $useWebp ? 'webp' : $source['extension'];
|
||||||
|
|
@ -136,4 +137,25 @@ final class ImageService
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function preparePublicDirectory(string $directory): void
|
||||||
|
{
|
||||||
|
$mediaRoot = dirname(__DIR__, 2) . '/public/media';
|
||||||
|
$normalized = rtrim(str_replace('\\', '/', $directory), '/');
|
||||||
|
$normalizedRoot = rtrim(str_replace('\\', '/', $mediaRoot), '/');
|
||||||
|
if ($normalized !== $normalizedRoot && !str_starts_with($normalized, $normalizedRoot . '/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod(dirname(__DIR__, 2) . '/data', 0710);
|
||||||
|
$relative = ltrim(substr($normalized, strlen($normalizedRoot)), '/');
|
||||||
|
$current = $normalizedRoot;
|
||||||
|
@chmod($current, 0755);
|
||||||
|
foreach (array_filter(explode('/', $relative), 'strlen') as $segment) {
|
||||||
|
$current .= '/' . $segment;
|
||||||
|
if (is_dir($current)) {
|
||||||
|
@chmod($current, 0755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,32 @@ final class Migrations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deux anciens schémas de l’historique ont circulé : l’un avec
|
||||||
|
// action/field/values, l’autre avec type/label/details. Le code
|
||||||
|
// courant accepte les données des deux et a besoin de leur union.
|
||||||
|
if ($name === '096_animal_history_compatibility.sql') {
|
||||||
|
$columns = array_column(
|
||||||
|
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'name',
|
||||||
|
);
|
||||||
|
$definitions = [
|
||||||
|
'action' => "TEXT NOT NULL DEFAULT 'history'",
|
||||||
|
'field' => 'TEXT',
|
||||||
|
'old_value' => 'TEXT',
|
||||||
|
'new_value' => 'TEXT',
|
||||||
|
'meta' => 'TEXT',
|
||||||
|
'type' => "TEXT NOT NULL DEFAULT 'history'",
|
||||||
|
'label' => "TEXT NOT NULL DEFAULT ''",
|
||||||
|
'details' => 'TEXT',
|
||||||
|
'user_id' => 'INTEGER',
|
||||||
|
];
|
||||||
|
foreach ($definitions as $column => $definition) {
|
||||||
|
if (!in_array($column, $columns, true)) {
|
||||||
|
$db->exec("ALTER TABLE animal_history ADD COLUMN $column $definition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql';
|
$relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql';
|
||||||
if ($relaxesForeignKeys) {
|
if ($relaxesForeignKeys) {
|
||||||
$db->exec('PRAGMA foreign_keys = OFF');
|
$db->exec('PRAGMA foreign_keys = OFF');
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,12 @@ final class SecurityService
|
||||||
public static function hardenRuntimeFiles(): void
|
public static function hardenRuntimeFiles(): void
|
||||||
{
|
{
|
||||||
$root = dirname(__DIR__, 2);
|
$root = dirname(__DIR__, 2);
|
||||||
$marker = $root . '/storage/.security-hardened';
|
$marker = $root . '/storage/.security-hardened-v2';
|
||||||
if (is_file($marker) && filemtime($marker) > time() - 86400) {
|
if (is_file($marker) && filemtime($marker) > time() - 86400) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
foreach (
|
foreach (
|
||||||
[
|
[
|
||||||
$root . '/data',
|
|
||||||
$root . '/storage',
|
$root . '/storage',
|
||||||
$root . '/data/private-media',
|
$root . '/data/private-media',
|
||||||
$root . '/data/medical-documents',
|
$root . '/data/medical-documents',
|
||||||
|
|
@ -45,6 +44,13 @@ final class SecurityService
|
||||||
@chmod($dir, 0700);
|
@chmod($dir, 0700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `public/media` may be a symlink into `data` (notably on YunoHost).
|
||||||
|
// Keep the data root traversable without making its private contents
|
||||||
|
// listable, then restore web-safe permissions only below public media.
|
||||||
|
if (is_dir($root . '/data')) {
|
||||||
|
@chmod($root . '/data', 0710);
|
||||||
|
}
|
||||||
|
self::hardenPublicTree($root . '/public/media');
|
||||||
foreach (
|
foreach (
|
||||||
[
|
[
|
||||||
$root . '/.env',
|
$root . '/.env',
|
||||||
|
|
@ -94,4 +100,19 @@ final class SecurityService
|
||||||
@chmod($item->getPathname(), $item->isDir() ? 0700 : 0600);
|
@chmod($item->getPathname(), $item->isDir() ? 0700 : 0600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function hardenPublicTree(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@chmod($path, 0755);
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST,
|
||||||
|
);
|
||||||
|
foreach ($it as $item) {
|
||||||
|
@chmod($item->getPathname(), $item->isDir() ? 0755 : 0644);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,7 @@ final class UpdateService
|
||||||
{
|
{
|
||||||
self::clearManagedCode($root);
|
self::clearManagedCode($root);
|
||||||
self::copyManagedCode($source, $root);
|
self::copyManagedCode($source, $root);
|
||||||
|
self::normalizePublicCodePermissions($root . '/public');
|
||||||
foreach (['data', 'storage', 'public/media'] as $path) {
|
foreach (['data', 'storage', 'public/media'] as $path) {
|
||||||
self::ensureDirectory($root . '/' . $path, 0770);
|
self::ensureDirectory($root . '/' . $path, 0770);
|
||||||
}
|
}
|
||||||
|
|
@ -466,6 +467,7 @@ final class UpdateService
|
||||||
{
|
{
|
||||||
self::clearManagedCode($root);
|
self::clearManagedCode($root);
|
||||||
self::copyManagedCode($snapshot, $root);
|
self::copyManagedCode($snapshot, $root);
|
||||||
|
self::normalizePublicCodePermissions($root . '/public');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function copyManagedCode(string $source, string $target): void
|
private static function copyManagedCode(string $source, string $target): void
|
||||||
|
|
@ -558,6 +560,24 @@ final class UpdateService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizePublicCodePermissions(string $publicRoot): void
|
||||||
|
{
|
||||||
|
if (!is_dir($publicRoot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@chmod($publicRoot, 0755);
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($publicRoot, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST,
|
||||||
|
);
|
||||||
|
foreach ($it as $item) {
|
||||||
|
if ($item->isLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
@chmod($item->getPathname(), $item->isDir() ? 0755 : 0644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function removeTree(string $path): void
|
private static function removeTree(string $path): void
|
||||||
{
|
{
|
||||||
if (is_link($path) || is_file($path)) {
|
if (is_link($path) || is_file($path)) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<section class="card h-100">
|
<section class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.23</span>
|
<span class="badge bg-blue-lt mb-2">1.0.0-rc.24</span>
|
||||||
<h3>Fiabilité des portées</h3>
|
<h3>Fiabilité des portées</h3>
|
||||||
<p class="text-muted">La création d’une fratrie ne dépend plus d’un ancien traitement de photo et
|
<p class="text-muted">La création d’une fratrie ne dépend plus d’un ancien traitement de photo et
|
||||||
son historique reste compatible avec les bases existantes.</p>
|
son historique reste compatible avec les bases existantes.</p>
|
||||||
|
|
|
||||||
3
migrations/096_animal_history_compatibility.sql
Normal file
3
migrations/096_animal_history_compatibility.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- La structure est harmonisée conditionnellement par Migrations::run afin de
|
||||||
|
-- prendre en charge les deux schémas historiques sans recréer la table.
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -87,6 +87,15 @@ try {
|
||||||
'L’historique des portées ne renseigne pas son action',
|
'L’historique des portées ne renseigne pas son action',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
$test('Les médias publics restent accessibles au serveur web', function () use ($assert): void {
|
||||||
|
$security = file_get_contents(__DIR__ . '/../app/Services/SecurityService.php');
|
||||||
|
$images = file_get_contents(__DIR__ . '/../app/Services/ImageService.php');
|
||||||
|
$updates = file_get_contents(__DIR__ . '/../app/Services/UpdateService.php');
|
||||||
|
$assert(str_contains($security, "@chmod(\$root . '/data', 0710)"), 'La racine des données ne reste pas traversable');
|
||||||
|
$assert(str_contains($security, "hardenPublicTree(\$root . '/public/media')"), 'Les médias publics ne sont pas réparés');
|
||||||
|
$assert(str_contains($images, 'preparePublicDirectory($directory)'), 'Les nouveaux dossiers publics ne sont pas préparés');
|
||||||
|
$assert(str_contains($updates, "normalizePublicCodePermissions(\$root . '/public')"), 'Le code public n’est pas normalisé après mise à jour');
|
||||||
|
});
|
||||||
$test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void {
|
$test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void {
|
||||||
$routes = $db
|
$routes = $db
|
||||||
->query('SELECT accro FROM ref_med_administration ORDER BY id')
|
->query('SELECT accro FROM ref_med_administration ORDER BY id')
|
||||||
|
|
@ -97,7 +106,7 @@ try {
|
||||||
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
||||||
'name',
|
'name',
|
||||||
);
|
);
|
||||||
foreach (['type', 'label', 'details'] as $column) {
|
foreach (['action', 'field', 'old_value', 'new_value', 'meta', 'type', 'label', 'details', 'user_id'] as $column) {
|
||||||
$assert(in_array($column, $historyColumns, true), 'Colonne historique absente : ' . $column);
|
$assert(in_array($column, $historyColumns, true), 'Colonne historique absente : ' . $column);
|
||||||
}
|
}
|
||||||
$assert(
|
$assert(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue