diff --git a/TODO.md b/TODO.md index 455abb9..fbfbfc3 100644 --- a/TODO.md +++ b/TODO.md @@ -50,3 +50,24 @@ - [x] `search.php`: show cover thumbnail on result cards - [x] `student-preview.php`: use `$coverMap` instead of `banner_path` - [x] Migration applied and file moved to `applied/` + +- [x] Remove `required` from all form inputs in admin add/edit + - [x] Introduced `$adminMode` flag in `form.php` (true when `$mode` is `'add'` or `'edit'`) + - [x] Hidden "champs obligatoires" note in admin mode + - [x] All `$required = true` callers in `form.php`, `fieldset-tfe-info.php`, `fieldset-academic.php`, `fieldset-licence-explanation.php`, `fieldset-files.php` changed to `!$adminMode` + - [x] Hardcoded `required` HTML attributes in `fieldset-tfe-info.php` (synopsis, objet radios), `fieldset-licence-explanation.php` (access type radios), `jury-fieldset.php` (promoteur, lecteurs interne/externe) gated on `!$adminMode` + - [x] Dynamic JS `ulbInput.required` in jury fieldset also gated + - [x] Remove server-side validation for orientation, ap, finality, licence, jury roles in `ThesisEditController::save()` — admins can save partial records + - [x] Same for `ThesisCreateController::submit()`: added `$adminMode` param, pass `true` from `admin/actions/formulaire.php` + +- [x] Encrypt SMTP password at rest (AES-256-GCM) + - [x] `app/.env` — holds `APP_KEY` (base64, 32 bytes); added to `.gitignore` + - [x] `src/Crypto.php` — `encrypt()` / `decrypt()` / `isEncrypted()` via OpenSSL AES-256-GCM + - [x] `SmtpRelay::getSettings()` — decrypts password after DB fetch + - [x] `SmtpRelay::updateSettings()` — encrypts password before DB write + - [x] `parametres.php` template — password field no longer pre-filled (ciphertext never sent to browser) + - [x] Migration `018_encrypt_smtp_password.php` — encrypted existing plaintext in DB; moved to applied/ + - [x] `justfile` — `deploy` calls `deploy-env` (uploads `.env` only if remote doesn't exist yet) + - [x] `justfile` — `deploy-env` recipe: safe upload with guards + - [x] `justfile` — `reencrypt-password` recipe: rotates APP_KEY on remote DB + - [x] `scripts/reencrypt-smtp-password.php` — decrypts with old key, re-encrypts with new key, updates `.env` diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php index 6f404a4..467f378 100644 --- a/app/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -33,7 +33,7 @@ $authorName = $_POST['auteurice'] ?? 'unknown'; try { $ctrl = ThesisCreateController::make(); - $thesisId = $ctrl->submit($_POST, $_FILES); + $thesisId = $ctrl->submit($_POST, $_FILES, true); $identifier = $ctrl->getIdentifier($thesisId); $logger->logSubmission('admin', $thesisId, $identifier, $authorName); diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index d969ba9..da7aad1 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -139,10 +139,10 @@ class ThesisCreateController * @return int The newly created thesis ID. * @throws Exception On validation or DB error. */ - public function submit(array $post, array $files): int + public function submit(array $post, array $files, bool $adminMode = false): int { // ── 1. Validate + sanitise ──────────────────────────────────────────── - $data = $this->validateAndSanitise($post); + $data = $this->validateAndSanitise($post, $adminMode); // ── 1b. Duplicate detection ─────────────────────────────────────────── require_once APP_ROOT . '/src/DuplicateThesisException.php'; @@ -293,7 +293,7 @@ class ThesisCreateController * @return array * @throws Exception on validation failure. */ - private function validateAndSanitise(array $post): array + private function validateAndSanitise(array $post, bool $adminMode = false): array { // Split authors by comma, trim, filter empty, sort alphabetically. $authorRaw = $this->sanitiseString($post['auteurice'] ?? ''); @@ -321,18 +321,18 @@ class ThesisCreateController throw new Exception('Année invalide. Veuillez entrer une année valide.'); } - $orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT); - if ($orientationId === false) { + $orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT) ?: null; + if (!$adminMode && !$orientationId) { throw new Exception('Veuillez sélectionner une orientation.'); } - $apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT); - if ($apProgramId === false) { + $apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT) ?: null; + if (!$adminMode && !$apProgramId) { throw new Exception('Veuillez sélectionner un Atelier Pratique.'); } - $finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT); - if ($finalityId === false) { + $finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT) ?: null; + if (!$adminMode && !$finalityId) { throw new Exception('Veuillez sélectionner une finalité.'); } @@ -412,13 +412,13 @@ class ThesisCreateController $juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0]; } - if (!$hasPromoteur) { + if (!$adminMode && !$hasPromoteur) { throw new Exception('Veuillez indiquer au moins un·e promoteur·ice interne.'); } - if (!$hasLecteurInt) { + if (!$adminMode && !$hasLecteurInt) { throw new Exception('Veuillez indiquer au moins un·e lecteur·ice interne.'); } - if (!$hasLecteurExt) { + if (!$adminMode && !$hasLecteurExt) { throw new Exception('Veuillez indiquer au moins un·e lecteur·ice externe.'); } @@ -441,7 +441,7 @@ class ThesisCreateController } } } - if (empty($languageIds)) { + if (!$adminMode && empty($languageIds)) { throw new Exception('Veuillez sélectionner au moins une langue.'); } @@ -449,13 +449,13 @@ class ThesisCreateController $formatIds = isset($post['formats']) && is_array($post['formats']) ? array_map('intval', $post['formats']) : []; - if (empty($formatIds)) { + if (!$adminMode && empty($formatIds)) { throw new Exception('Veuillez sélectionner au moins un format.'); } $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; $licenseCustom = trim($post['license_custom'] ?? ''); - if (!$licenseId && $licenseCustom === '') { + if (!$adminMode && !$licenseId && $licenseCustom === '') { throw new Exception('Veuillez sélectionner une licence ou en préciser une.'); } diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 3044cdb..7bb4c9b 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -181,62 +181,8 @@ class ThesisEditController $errors[] = "L'année est invalide."; } $orientationId = intval($post['orientation'] ?? 0); - if ($orientationId <= 0) { - $errors[] = "L'orientation est requise."; - } $apProgramId = intval($post['ap'] ?? 0); - if ($apProgramId <= 0) { - $errors[] = "L'atelier pluridisciplinaire est requis."; - } $finalityId = intval($post['finality'] ?? 0); - if ($finalityId <= 0) { - $errors[] = 'La finalité est requise.'; - } - - // Languages - $langIds = isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : []; - if (empty($langIds)) { - $errors[] = 'Au moins une langue est requise.'; - } - - // Formats - $fmtIds = isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : []; - if (empty($fmtIds)) { - $errors[] = 'Au moins un format est requis.'; - } - - // Licence - $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; - $licenseCustom = trim($post['license_custom'] ?? ''); - if (!$licenseId && $licenseCustom === '') { - $errors[] = 'Une licence est requise.'; - } - - // Jury - $hasPromoteur = !empty(trim($post['jury_promoteur'] ?? '')); - $hasLecteurInt = false; - $hasLecteurExt = false; - foreach ($post['jury_lecteur_interne'] ?? [] as $n) { - if (trim((string)$n) !== '') { - $hasLecteurInt = true; - break; - } - } - foreach ($post['jury_lecteur_externe'] ?? [] as $n) { - if (trim((string)$n) !== '') { - $hasLecteurExt = true; - break; - } - } - if (!$hasPromoteur) { - $errors[] = 'Un·e promoteur·ice interne est requis.'; - } - if (!$hasLecteurInt) { - $errors[] = 'Au moins un·e lecteur·ice interne est requis.'; - } - if (!$hasLecteurExt) { - $errors[] = 'Au moins un·e lecteur·ice externe est requis.'; - } if (!empty($errors)) { throw new RuntimeException(implode(' ', $errors)); diff --git a/app/templates/partials/form/fieldset-academic.php b/app/templates/partials/form/fieldset-academic.php index 32d4df2..fe40e85 100644 --- a/app/templates/partials/form/fieldset-academic.php +++ b/app/templates/partials/form/fieldset-academic.php @@ -14,12 +14,13 @@ $oldFn = $oldFn ?? (function_exists('old') ? 'old' : fn($k, $d = '') => $d); $withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs; $formData = $formData ?? []; +$adminMode = $adminMode ?? false; ?>
Cadre académique 2000, 'max' => date('Y') + 1]); @@ -28,19 +29,19 @@ $formData = $formData ?? []; diff --git a/app/templates/partials/form/fieldset-files.php b/app/templates/partials/form/fieldset-files.php index fd1a208..8933859 100644 --- a/app/templates/partials/form/fieldset-files.php +++ b/app/templates/partials/form/fieldset-files.php @@ -8,8 +8,10 @@ * 3. TFE (obligatoire) * 4. Annexes éventuelles (optionnel) * - * Variables consumed: none beyond APP_ROOT (always available). + * Variables consumed: + * bool $adminMode — when true, no field is required (admin add/edit mode). */ +$adminMode = $adminMode ?? false; ?>
Fichiers @@ -27,7 +29,7 @@ $label = 'Note d\'intention :'; $accept ='.pdf'; $hint = 'Format PDF uniquement.'; - $required = true; + $required = !$adminMode; include APP_ROOT . '/templates/partials/form/file-field.php'; ?> diff --git a/app/templates/partials/form/fieldset-licence-explanation.php b/app/templates/partials/form/fieldset-licence-explanation.php index dd08197..2b45f8e 100644 --- a/app/templates/partials/form/fieldset-licence-explanation.php +++ b/app/templates/partials/form/fieldset-licence-explanation.php @@ -25,6 +25,7 @@ $interneEnabled = $interneEnabled ?? true; $interditEnabled = $interditEnabled ?? true; $generalitiesHtml = $generalitiesHtml ?? ''; $defaultAccessTypeId = $defaultAccessTypeId ?? 2; +$adminMode = $adminMode ?? false; ?>
Degrés d'ouverture et licences @@ -55,7 +56,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
@@ -65,7 +66,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
@@ -75,7 +76,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
@@ -88,7 +89,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;

Licence du TFE

diff --git a/app/templates/partials/form/fieldset-tfe-info.php b/app/templates/partials/form/fieldset-tfe-info.php index 374c848..46a1c41 100644 --- a/app/templates/partials/form/fieldset-tfe-info.php +++ b/app/templates/partials/form/fieldset-tfe-info.php @@ -18,6 +18,7 @@ $withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs; $allowedObjet = $allowedObjet ?? []; $formData = $formData ?? []; $synopsisExtra = $synopsisExtra ?? ''; +$adminMode = $adminMode ?? false; ?>
Informations du TFE @@ -30,7 +31,8 @@ $synopsisExtra = $synopsisExtra ?? ''; @@ -42,7 +44,7 @@ $synopsisExtra = $synopsisExtra ?? ''; @@ -51,7 +53,7 @@ $synopsisExtra = $synopsisExtra ?? ''; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> 'name']); $hint = 'Séparez les auteur·ices par des virgules.'; include APP_ROOT . '/templates/partials/form/text-field.php'; @@ -64,9 +66,9 @@ $synopsisExtra = $synopsisExtra ?? ''; ?>
- + -
diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index f9c3aa7..704c727 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -56,6 +56,8 @@ // ── Defaults ────────────────────────────────────────────────────────────────── $mode = $mode ?? 'add'; +// In admin add/edit, no field is required (admins can save partial records) +$adminMode = ($mode === 'add' || $mode === 'edit'); $formAction = $formAction ?? ''; $hiddenFields = $hiddenFields ?? ''; $formData = $formData ?? []; @@ -135,7 +137,9 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
+

* Champs obligatoires

+ " - > + > Si votre TFE contient une langue absente de la liste, précisez-la ici. @@ -234,7 +238,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; $label = "Format(s) du TFE :"; $options = $formatTypes; $checked = $formData["formats"] ?? []; - $required = true; + $required = !$adminMode; $hxPost = "/partage/format-website-fragment"; $hxTarget = "#website-url-fieldset"; include APP_ROOT . "/templates/partials/form/checkbox-list.php"; @@ -560,7 +564,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; $label = "Adresse e-mail :"; $value = $oldFn("confirmation_email"); $type = "email"; - $required = true; + $required = !$adminMode; $placeholder = "ton.email@exemple.be"; $hint = "Nécessaire pour recevoir le récapitulatif de ta soumission."; diff --git a/app/templates/partials/form/jury-fieldset.php b/app/templates/partials/form/jury-fieldset.php index a54845e..dcaa2a9 100644 --- a/app/templates/partials/form/jury-fieldset.php +++ b/app/templates/partials/form/jury-fieldset.php @@ -23,6 +23,7 @@ $juryPresident = $juryPresident ?? null; $showPresident = $showPresident ?? false; $showPromoteurUlb = $showPromoteurUlb ?? true; $promoteurUlbConditional = $promoteurUlbConditional ?? false; +$adminMode = $adminMode ?? false; // Add-mode repopulation from flash data $addMode = ($juryPromoteur === null && $juryPromoteurUlb === null && empty($lecteursInternes) && empty($lecteursExternes) && $juryPresident === null); @@ -45,9 +46,9 @@ if ($addMode && function_exists('old')) {
- + + value="" placeholder="Nom" >
@@ -70,7 +71,7 @@ if ($addMode && function_exists('old')) { var isApprofondi = text.includes('approfondi'); ulbRow.style.display = isApprofondi ? '' : 'none'; if (ulbInput) { - ulbInput.required = isApprofondi; + ulbInput.required = ; ulbInput.disabled = !isApprofondi; if (!isApprofondi) ulbInput.value = ''; } @@ -86,11 +87,11 @@ if ($addMode && function_exists('old')) {
- Lecteur·ice(s) interne * + Lecteur·ice(s) interne*' ?>
- aria-label="Lecteur·ice interne 1 — nom"> @@ -100,7 +101,7 @@ if ($addMode && function_exists('old')) {
+ aria-label="Lecteur·ice interne — nom"> @@ -116,11 +117,11 @@ if ($addMode && function_exists('old')) {
- Lecteur·ice(s) externe * + Lecteur·ice(s) externe*' ?>
- aria-label="Lecteur·ice externe 1 — nom"> @@ -130,7 +131,7 @@ if ($addMode && function_exists('old')) {
+ aria-label="Lecteur·ice externe — nom"> diff --git a/justfile b/justfile index 7194ad1..2822109 100644 --- a/justfile +++ b/justfile @@ -49,6 +49,7 @@ deploy: --exclude '.claude' \ --exclude '.pi' \ --exclude '.DS_Store' \ + --exclude '.env' \ --exclude 'storage/xamxam.db' \ --exclude 'storage/theses' \ --exclude 'storage/covers' \ @@ -62,6 +63,44 @@ deploy: app/ xamxam:/var/www/xamxam/ ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}" ssh xamxam "cd /var/www/xamxam && php migrations/run.php /var/www/xamxam/storage/xamxam.db" + # Sync .env separately (excluded above to avoid accidental overwrite on subsequent deploys) + @just deploy-env + +[group('deploy')] +deploy-env: + # Upload app/.env only if it exists locally; never overwrites a remote .env that already has APP_KEY. + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f app/.env ]; then + echo "WARNING: app/.env not found locally — skipping." + exit 0 + fi + if ssh xamxam '[ -f /var/www/xamxam/.env ]'; then + echo "Remote .env already exists — skipping to avoid overwriting key." + echo "Run 'just reencrypt-password' if you rotated APP_KEY." + else + rsync -v --progress app/.env xamxam:/var/www/xamxam/.env + ssh xamxam "chmod 640 /var/www/xamxam/.env && chown www-data:xamxam /var/www/xamxam/.env" + echo ".env uploaded." + fi + +[group('deploy')] +reencrypt-password new_key_b64="": + # Re-encrypt the SMTP password in the remote DB after rotating APP_KEY. + # Usage: + # 1. Generate a new key: php -r "echo base64_encode(random_bytes(32));" + # 2. Run: just reencrypt-password + # 3. Update app/.env locally with the new key, then run: just deploy-env + #!/usr/bin/env bash + set -euo pipefail + if [ -z "{{new_key_b64}}" ]; then + echo "Usage: just reencrypt-password " + echo "Generate a key: php -r \"echo base64_encode(random_bytes(32));\"" + exit 1 + fi + # Run the re-encryption script on the server using the current key (from remote .env) + # and the supplied new key. + ssh xamxam "php /var/www/xamxam/scripts/reencrypt-smtp-password.php '{{new_key_b64}}' /var/www/xamxam/storage/xamxam.db" [group('deploy')] deploy-db: diff --git a/scripts/reencrypt-smtp-password.php b/scripts/reencrypt-smtp-password.php new file mode 100644 index 0000000..25a9a74 --- /dev/null +++ b/scripts/reencrypt-smtp-password.php @@ -0,0 +1,101 @@ +#!/usr/bin/env php + [DB_PATH] + * + * Steps performed: + * 1. Read old APP_KEY from the .env file adjacent to this script's app root. + * 2. Decrypt the current password from smtp_settings using the old key. + * 3. Encrypt it with the new key. + * 4. Write the new ciphertext back to the DB. + * 5. Update app/.env with the new APP_KEY. + * + * After running this script, redeploy or manually update app/.env on every + * environment that shares the same database. + */ + +$newKeyB64 = $argv[1] ?? ''; +$dbPath = $argv[2] ?? null; + +if ($newKeyB64 === '') { + fwrite(STDERR, "Usage: php reencrypt-smtp-password.php [DB_PATH]\n"); + fwrite(STDERR, "Generate a key: php -r \"echo base64_encode(random_bytes(32));\"\n"); + exit(1); +} + +$newKeyRaw = base64_decode($newKeyB64, strict: true); +if ($newKeyRaw === false || strlen($newKeyRaw) !== 32) { + fwrite(STDERR, "ERROR: new key must be a base64-encoded 32-byte value.\n"); + exit(1); +} + +// Locate app root (script lives in app/scripts/ or scripts/ next to app/) +$candidates = [ + __DIR__ . '/../app', // repo root / scripts/ + __DIR__ . '/..', // app / scripts/ +]; +$appRoot = null; +foreach ($candidates as $c) { + if (file_exists(realpath($c) . '/src/Crypto.php')) { + $appRoot = realpath($c); + break; + } +} +if ($appRoot === null) { + fwrite(STDERR, "ERROR: could not locate app root (looking for src/Crypto.php).\n"); + exit(1); +} + +define('APP_ROOT', $appRoot); +require_once $appRoot . '/src/Crypto.php'; + +$dbPath = $dbPath ?? $appRoot . '/storage/xamxam.db'; +if (!file_exists($dbPath)) { + fwrite(STDERR, "ERROR: database not found: $dbPath\n"); + exit(1); +} + +$pdo = new PDO('sqlite:' . $dbPath); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$row = $pdo->query("SELECT password FROM smtp_settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); +if (!$row) { + fwrite(STDERR, "ERROR: no smtp_settings row found.\n"); + exit(1); +} + +// Decrypt with the old key (read from existing .env via Crypto::decrypt) +$plaintext = Crypto::decrypt($row['password']); +if ($plaintext === '') { + echo "Password is empty — nothing to re-encrypt.\n"; + exit(0); +} + +// Encrypt with the new key directly (bypass the singleton so we can supply a different key) +$iv = random_bytes(12); +$tag = ''; +$cipher = openssl_encrypt($plaintext, 'aes-256-gcm', $newKeyRaw, OPENSSL_RAW_DATA, $iv, $tag, '', 16); +if ($cipher === false) { + fwrite(STDERR, "ERROR: encryption failed: " . openssl_error_string() . "\n"); + exit(1); +} +$newBlob = base64_encode($iv . $tag . $cipher); + +// Write new ciphertext to DB +$pdo->prepare("UPDATE smtp_settings SET password = ? WHERE id = 1")->execute([$newBlob]); +echo "DB updated with new ciphertext.\n"; + +// Update .env +$envFile = $appRoot . '/.env'; +$envContent = file_exists($envFile) ? file_get_contents($envFile) : ''; +if (preg_match('/^APP_KEY=.*/m', $envContent)) { + $envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY=' . $newKeyB64, $envContent); +} else { + $envContent .= "APP_KEY={$newKeyB64}\n"; +} +file_put_contents($envFile, $envContent); +echo ".env updated with new APP_KEY.\n"; +echo "Done. Redeploy app/.env to all environments: just deploy-env\n";