Remove required from all admin add/edit form inputs

- Skip required-field validation for orientation/ap/finality/licence/jury in admin add+edit
This commit is contained in:
Pontoporeia
2026-05-08 12:40:06 +02:00
parent 5735ccbc38
commit 95fcbc919a
12 changed files with 216 additions and 98 deletions

21
TODO.md
View File

@@ -50,3 +50,24 @@
- [x] `search.php`: show cover thumbnail on result cards - [x] `search.php`: show cover thumbnail on result cards
- [x] `student-preview.php`: use `$coverMap` instead of `banner_path` - [x] `student-preview.php`: use `$coverMap` instead of `banner_path`
- [x] Migration applied and file moved to `applied/` - [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`

View File

@@ -33,7 +33,7 @@ $authorName = $_POST['auteurice'] ?? 'unknown';
try { try {
$ctrl = ThesisCreateController::make(); $ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES); $thesisId = $ctrl->submit($_POST, $_FILES, true);
$identifier = $ctrl->getIdentifier($thesisId); $identifier = $ctrl->getIdentifier($thesisId);
$logger->logSubmission('admin', $thesisId, $identifier, $authorName); $logger->logSubmission('admin', $thesisId, $identifier, $authorName);

View File

@@ -139,10 +139,10 @@ class ThesisCreateController
* @return int The newly created thesis ID. * @return int The newly created thesis ID.
* @throws Exception On validation or DB error. * @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 ──────────────────────────────────────────── // ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post); $data = $this->validateAndSanitise($post, $adminMode);
// ── 1b. Duplicate detection ─────────────────────────────────────────── // ── 1b. Duplicate detection ───────────────────────────────────────────
require_once APP_ROOT . '/src/DuplicateThesisException.php'; require_once APP_ROOT . '/src/DuplicateThesisException.php';
@@ -293,7 +293,7 @@ class ThesisCreateController
* @return array<string, mixed> * @return array<string, mixed>
* @throws Exception on validation failure. * @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. // Split authors by comma, trim, filter empty, sort alphabetically.
$authorRaw = $this->sanitiseString($post['auteurice'] ?? ''); $authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
@@ -321,18 +321,18 @@ class ThesisCreateController
throw new Exception('Année invalide. Veuillez entrer une année valide.'); throw new Exception('Année invalide. Veuillez entrer une année valide.');
} }
$orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT); $orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT) ?: null;
if ($orientationId === false) { if (!$adminMode && !$orientationId) {
throw new Exception('Veuillez sélectionner une orientation.'); throw new Exception('Veuillez sélectionner une orientation.');
} }
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT); $apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT) ?: null;
if ($apProgramId === false) { if (!$adminMode && !$apProgramId) {
throw new Exception('Veuillez sélectionner un Atelier Pratique.'); throw new Exception('Veuillez sélectionner un Atelier Pratique.');
} }
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT); $finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT) ?: null;
if ($finalityId === false) { if (!$adminMode && !$finalityId) {
throw new Exception('Veuillez sélectionner une finalité.'); 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]; $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.'); 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.'); 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.'); 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.'); throw new Exception('Veuillez sélectionner au moins une langue.');
} }
@@ -449,13 +449,13 @@ class ThesisCreateController
$formatIds = isset($post['formats']) && is_array($post['formats']) $formatIds = isset($post['formats']) && is_array($post['formats'])
? array_map('intval', $post['formats']) ? array_map('intval', $post['formats'])
: []; : [];
if (empty($formatIds)) { if (!$adminMode && empty($formatIds)) {
throw new Exception('Veuillez sélectionner au moins un format.'); throw new Exception('Veuillez sélectionner au moins un format.');
} }
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$licenseCustom = trim($post['license_custom'] ?? ''); $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.'); throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
} }

View File

@@ -181,62 +181,8 @@ class ThesisEditController
$errors[] = "L'année est invalide."; $errors[] = "L'année est invalide.";
} }
$orientationId = intval($post['orientation'] ?? 0); $orientationId = intval($post['orientation'] ?? 0);
if ($orientationId <= 0) {
$errors[] = "L'orientation est requise.";
}
$apProgramId = intval($post['ap'] ?? 0); $apProgramId = intval($post['ap'] ?? 0);
if ($apProgramId <= 0) {
$errors[] = "L'atelier pluridisciplinaire est requis.";
}
$finalityId = intval($post['finality'] ?? 0); $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)) { if (!empty($errors)) {
throw new RuntimeException(implode(' ', $errors)); throw new RuntimeException(implode(' ', $errors));

View File

@@ -14,12 +14,13 @@
$oldFn = $oldFn ?? (function_exists('old') ? 'old' : fn($k, $d = '') => $d); $oldFn = $oldFn ?? (function_exists('old') ? 'old' : fn($k, $d = '') => $d);
$withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs; $withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs;
$formData = $formData ?? []; $formData = $formData ?? [];
$adminMode = $adminMode ?? false;
?> ?>
<fieldset> <fieldset>
<legend>Cadre académique</legend> <legend>Cadre académique</legend>
<?php <?php
$name = 'année'; $label = 'Année :'; $value = $oldFn('année'); $required = true; $name = 'année'; $label = 'Année :'; $value = $oldFn('année'); $required = !$adminMode;
$type = 'number'; $type = 'number';
$placeholder = date('Y'); $placeholder = date('Y');
$attrs = $withAutofocusFn('année', ['min' => 2000, 'max' => date('Y') + 1]); $attrs = $withAutofocusFn('année', ['min' => 2000, 'max' => date('Y') + 1]);
@@ -28,19 +29,19 @@ $formData = $formData ?? [];
<?php <?php
$name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $name = 'orientation'; $label = 'Orientation :'; $options = $orientations;
$selected = $formData['orientation'] ?? ''; $required = true; $placeholder = ''; $selected = $formData['orientation'] ?? ''; $required = !$adminMode; $placeholder = '';
$attrs = $withAutofocusFn('orientation'); $attrs = $withAutofocusFn('orientation');
include APP_ROOT . '/templates/partials/form/select-field.php'; include APP_ROOT . '/templates/partials/form/select-field.php';
?> ?>
<?php <?php
$name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms;
$selected = $formData['ap'] ?? ''; $required = true; $placeholder = ''; $selected = $formData['ap'] ?? ''; $required = !$adminMode; $placeholder = '';
$attrs = $withAutofocusFn('ap'); $attrs = $withAutofocusFn('ap');
include APP_ROOT . '/templates/partials/form/select-field.php'; include APP_ROOT . '/templates/partials/form/select-field.php';
?> ?>
<?php <?php
$name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes;
$selected = $formData['finality'] ?? ''; $required = true; $placeholder = ''; $selected = $formData['finality'] ?? ''; $required = !$adminMode; $placeholder = '';
$attrs = $withAutofocusFn('finality'); $attrs = $withAutofocusFn('finality');
include APP_ROOT . '/templates/partials/form/select-field.php'; include APP_ROOT . '/templates/partials/form/select-field.php';
?> ?>

View File

@@ -8,8 +8,10 @@
* 3. TFE (obligatoire) * 3. TFE (obligatoire)
* 4. Annexes éventuelles (optionnel) * 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;
?> ?>
<fieldset> <fieldset>
<legend>Fichiers</legend> <legend>Fichiers</legend>
@@ -27,7 +29,7 @@
$label = 'Note d\'intention :'; $label = 'Note d\'intention :';
$accept ='.pdf'; $accept ='.pdf';
$hint = 'Format PDF uniquement.'; $hint = 'Format PDF uniquement.';
$required = true; $required = !$adminMode;
include APP_ROOT . '/templates/partials/form/file-field.php'; include APP_ROOT . '/templates/partials/form/file-field.php';
?> ?>

View File

@@ -25,6 +25,7 @@ $interneEnabled = $interneEnabled ?? true;
$interditEnabled = $interditEnabled ?? true; $interditEnabled = $interditEnabled ?? true;
$generalitiesHtml = $generalitiesHtml ?? ''; $generalitiesHtml = $generalitiesHtml ?? '';
$defaultAccessTypeId = $defaultAccessTypeId ?? 2; $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
$adminMode = $adminMode ?? false;
?> ?>
<fieldset class="licence-explanation"> <fieldset class="licence-explanation">
<legend>Degrés d'ouverture et licences</legend> <legend>Degrés d'ouverture et licences</legend>
@@ -55,7 +56,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
<div class="licence-degree"> <div class="licence-degree">
<label class="admin-checkbox-label"> <label class="admin-checkbox-label">
<input type="radio" name="access_type_id" value="1" <input type="radio" name="access_type_id" value="1"
<?= $selectedAccess === '1' ? 'checked' : '' ?> required> <?= $selectedAccess === '1' ? 'checked' : '' ?> <?= $adminMode ? '' : 'required' ?>>
<strong>🔓 Libre</strong> — Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. <strong>🔓 Libre</strong> — Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg.
</label> </label>
</div> </div>
@@ -65,7 +66,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
<div class="licence-degree"> <div class="licence-degree">
<label class="admin-checkbox-label"> <label class="admin-checkbox-label">
<input type="radio" name="access_type_id" value="2" <input type="radio" name="access_type_id" value="2"
<?= $selectedAccess === '2' ? 'checked' : '' ?> required> <?= $selectedAccess === '2' ? 'checked' : '' ?> <?= $adminMode ? '' : 'required' ?>>
<strong>🔒 Interne</strong> — Mon TFE n'est accessible que sur place en physique. Une note descriptive est disponible sur le site. <strong>🔒 Interne</strong> — Mon TFE n'est accessible que sur place en physique. Une note descriptive est disponible sur le site.
</label> </label>
</div> </div>
@@ -75,7 +76,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
<div class="licence-degree"> <div class="licence-degree">
<label class="admin-checkbox-label"> <label class="admin-checkbox-label">
<input type="radio" name="access_type_id" value="3" <input type="radio" name="access_type_id" value="3"
<?= $selectedAccess === '3' ? 'checked' : '' ?> required> <?= $selectedAccess === '3' ? 'checked' : '' ?> <?= $adminMode ? '' : 'required' ?>>
<strong>🚫 Interdit</strong> — Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site. <strong>🚫 Interdit</strong> — Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.
</label> </label>
</div> </div>
@@ -88,7 +89,7 @@ $defaultAccessTypeId = $defaultAccessTypeId ?? 2;
<h3>Licence du TFE</h3> <h3>Licence du TFE</h3>
<?php <?php
$name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes;
$selected = $formData['license_id'] ?? ''; $placeholder = '— Sélectionner —'; $required = true; $selected = $formData['license_id'] ?? ''; $placeholder = '— Sélectionner —'; $required = !$adminMode;
include APP_ROOT . '/templates/partials/form/select-field.php'; include APP_ROOT . '/templates/partials/form/select-field.php';
?> ?>

View File

@@ -18,6 +18,7 @@ $withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs;
$allowedObjet = $allowedObjet ?? []; $allowedObjet = $allowedObjet ?? [];
$formData = $formData ?? []; $formData = $formData ?? [];
$synopsisExtra = $synopsisExtra ?? ''; $synopsisExtra = $synopsisExtra ?? '';
$adminMode = $adminMode ?? false;
?> ?>
<fieldset> <fieldset>
<legend>Informations du TFE</legend> <legend>Informations du TFE</legend>
@@ -30,7 +31,8 @@ $synopsisExtra = $synopsisExtra ?? '';
<?php foreach ($allowedObjet as $objetVal): ?> <?php foreach ($allowedObjet as $objetVal): ?>
<label class="admin-checkbox-label"> <label class="admin-checkbox-label">
<input type="radio" name="objet" value="<?= htmlspecialchars($objetVal) ?>" <input type="radio" name="objet" value="<?= htmlspecialchars($objetVal) ?>"
<?= ($oldFn('objet') ?: $allowedObjet[0]) === $objetVal ? 'checked' : '' ?> required> <?= ($oldFn('objet') ?: $allowedObjet[0]) === $objetVal ? 'checked' : '' ?>
<?= $adminMode ? '' : 'required' ?>>
<?= htmlspecialchars(ucfirst($objetVal)) ?> <?= htmlspecialchars(ucfirst($objetVal)) ?>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
@@ -42,7 +44,7 @@ $synopsisExtra = $synopsisExtra ?? '';
<?php endif; ?> <?php endif; ?>
<?php <?php
$name = 'titre'; $label = 'Titre du TFE :'; $value = $oldFn('titre'); $required = true; $name = 'titre'; $label = 'Titre du TFE :'; $value = $oldFn('titre'); $required = !$adminMode;
$attrs = $withAutofocusFn('titre'); $attrs = $withAutofocusFn('titre');
include APP_ROOT . '/templates/partials/form/text-field.php'; include APP_ROOT . '/templates/partials/form/text-field.php';
?> ?>
@@ -51,7 +53,7 @@ $synopsisExtra = $synopsisExtra ?? '';
include APP_ROOT . '/templates/partials/form/text-field.php'; include APP_ROOT . '/templates/partials/form/text-field.php';
?> ?>
<?php <?php
$name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = $oldFn('auteurice'); $required = true; $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = $oldFn('auteurice'); $required = !$adminMode;
$attrs = $withAutofocusFn('auteurice', ['autocomplete' => 'name']); $attrs = $withAutofocusFn('auteurice', ['autocomplete' => 'name']);
$hint = 'Séparez les auteur·ices par des virgules.'; $hint = 'Séparez les auteur·ices par des virgules.';
include APP_ROOT . '/templates/partials/form/text-field.php'; include APP_ROOT . '/templates/partials/form/text-field.php';
@@ -64,9 +66,9 @@ $synopsisExtra = $synopsisExtra ?? '';
?> ?>
<div> <div>
<label for="synopsis">Synopsis : <span class="asterisk">*</span></label> <label for="synopsis">Synopsis :<?= $adminMode ? '' : ' <span class="asterisk">*</span>' ?></label>
<?= $synopsisExtra ?> <?= $synopsisExtra ?>
<textarea id="synopsis" name="synopsis" rows="7" required <textarea id="synopsis" name="synopsis" rows="7" <?= $adminMode ? '' : 'required' ?>
<?= ($withAutofocusFn('synopsis')['autofocus'] ?? false) ? 'autofocus' : '' ?>><?= $oldFn('synopsis') ?></textarea> <?= ($withAutofocusFn('synopsis')['autofocus'] ?? false) ? 'autofocus' : '' ?>><?= $oldFn('synopsis') ?></textarea>
</div> </div>
</fieldset> </fieldset>

View File

@@ -56,6 +56,8 @@
// ── Defaults ────────────────────────────────────────────────────────────────── // ── Defaults ──────────────────────────────────────────────────────────────────
$mode = $mode ?? 'add'; $mode = $mode ?? 'add';
// In admin add/edit, no field is required (admins can save partial records)
$adminMode = ($mode === 'add' || $mode === 'edit');
$formAction = $formAction ?? ''; $formAction = $formAction ?? '';
$hiddenFields = $hiddenFields ?? ''; $hiddenFields = $hiddenFields ?? '';
$formData = $formData ?? []; $formData = $formData ?? [];
@@ -135,7 +137,9 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard> <form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard>
<?= $hiddenFields ?> <?= $hiddenFields ?>
<?php if (!$adminMode): ?>
<p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p> <p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p>
<?php endif; ?>
<!-- ═══════════════════ Informations du TFE ═══════════════════ --> <!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<?php <?php
@@ -172,7 +176,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$label = "Langue(s) du TFE :"; $label = "Langue(s) du TFE :";
$options = $languages; $options = $languages;
$checked = $formData["languages"] ?? []; $checked = $formData["languages"] ?? [];
$required = true; $required = !$adminMode;
$hxPost = $mode === 'partage' ? "/partage/language-autre-fragment" : "/admin/language-autre-fragment.php"; $hxPost = $mode === 'partage' ? "/partage/language-autre-fragment" : "/admin/language-autre-fragment.php";
$hxTarget = "#language-autre-row"; $hxTarget = "#language-autre-row";
$hxSwap = "outerHTML"; $hxSwap = "outerHTML";
@@ -191,7 +195,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
id="language_autre" id="language_autre"
name="language_autre" name="language_autre"
value="<?= $_langAutreValue ?>" value="<?= $_langAutreValue ?>"
<?= $_langAutreRequired ? 'required' : '' ?>> <?= (!$adminMode && $_langAutreRequired) ? 'required' : '' ?>>
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small> <small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
</div> </div>
</div> </div>
@@ -234,7 +238,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$label = "Format(s) du TFE :"; $label = "Format(s) du TFE :";
$options = $formatTypes; $options = $formatTypes;
$checked = $formData["formats"] ?? []; $checked = $formData["formats"] ?? [];
$required = true; $required = !$adminMode;
$hxPost = "/partage/format-website-fragment"; $hxPost = "/partage/format-website-fragment";
$hxTarget = "#website-url-fieldset"; $hxTarget = "#website-url-fieldset";
include APP_ROOT . "/templates/partials/form/checkbox-list.php"; include APP_ROOT . "/templates/partials/form/checkbox-list.php";
@@ -560,7 +564,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$label = "Adresse e-mail :"; $label = "Adresse e-mail :";
$value = $oldFn("confirmation_email"); $value = $oldFn("confirmation_email");
$type = "email"; $type = "email";
$required = true; $required = !$adminMode;
$placeholder = "ton.email@exemple.be"; $placeholder = "ton.email@exemple.be";
$hint = $hint =
"Nécessaire pour recevoir le récapitulatif de ta soumission."; "Nécessaire pour recevoir le récapitulatif de ta soumission.";

View File

@@ -23,6 +23,7 @@ $juryPresident = $juryPresident ?? null;
$showPresident = $showPresident ?? false; $showPresident = $showPresident ?? false;
$showPromoteurUlb = $showPromoteurUlb ?? true; $showPromoteurUlb = $showPromoteurUlb ?? true;
$promoteurUlbConditional = $promoteurUlbConditional ?? false; $promoteurUlbConditional = $promoteurUlbConditional ?? false;
$adminMode = $adminMode ?? false;
// Add-mode repopulation from flash data // Add-mode repopulation from flash data
$addMode = ($juryPromoteur === null && $juryPromoteurUlb === null && empty($lecteursInternes) && empty($lecteursExternes) && $juryPresident === null); $addMode = ($juryPromoteur === null && $juryPromoteurUlb === null && empty($lecteursInternes) && empty($lecteursExternes) && $juryPresident === null);
@@ -45,9 +46,9 @@ if ($addMode && function_exists('old')) {
<!-- Promoteur·ice interne --> <!-- Promoteur·ice interne -->
<div> <div>
<label for="jury_promoteur">Promoteur·ice interne : <span class="asterisk">*</span></label> <label for="jury_promoteur">Promoteur·ice interne :<?= $adminMode ? '' : ' <span class="asterisk">*</span>' ?></label>
<input type="text" id="jury_promoteur" name="jury_promoteur" <input type="text" id="jury_promoteur" name="jury_promoteur"
value="<?= htmlspecialchars($juryPromoteur ?? '') ?>" placeholder="Nom" required> value="<?= htmlspecialchars($juryPromoteur ?? '') ?>" placeholder="Nom" <?= $adminMode ? '' : 'required' ?>>
</div> </div>
<?php if ($showPromoteurUlb): ?> <?php if ($showPromoteurUlb): ?>
@@ -70,7 +71,7 @@ if ($addMode && function_exists('old')) {
var isApprofondi = text.includes('approfondi'); var isApprofondi = text.includes('approfondi');
ulbRow.style.display = isApprofondi ? '' : 'none'; ulbRow.style.display = isApprofondi ? '' : 'none';
if (ulbInput) { if (ulbInput) {
ulbInput.required = isApprofondi; ulbInput.required = <?= $adminMode ? 'false' : 'isApprofondi' ?>;
ulbInput.disabled = !isApprofondi; ulbInput.disabled = !isApprofondi;
if (!isApprofondi) ulbInput.value = ''; if (!isApprofondi) ulbInput.value = '';
} }
@@ -86,11 +87,11 @@ if ($addMode && function_exists('old')) {
<!-- Lecteur·ice(s) interne --> <!-- Lecteur·ice(s) interne -->
<fieldset class="admin-jury-lecteurs"> <fieldset class="admin-jury-lecteurs">
<legend>Lecteur·ice(s) interne <span class="asterisk">*</span></legend> <legend>Lecteur·ice(s) interne<?= $adminMode ? '' : ' <span class="asterisk">*</span>' ?></legend>
<div id="jury-lecteurs-internes-list" class="admin-jury-list"> <div id="jury-lecteurs-internes-list" class="admin-jury-list">
<?php if (empty($lecteursInternes)): ?> <?php if (empty($lecteursInternes)): ?>
<div class="admin-jury-entry"> <div class="admin-jury-entry">
<input type="text" name="jury_lecteur_interne[]" placeholder="Nom" required <input type="text" name="jury_lecteur_interne[]" placeholder="Nom" <?= $adminMode ? '' : 'required' ?>
aria-label="Lecteur·ice interne 1 — nom"> aria-label="Lecteur·ice interne 1 — nom">
<button type="button" class="btn btn--sm btn--ghost admin-btn-remove" <button type="button" class="btn btn--sm btn--ghost admin-btn-remove"
onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button> onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button>
@@ -100,7 +101,7 @@ if ($addMode && function_exists('old')) {
<div class="admin-jury-entry"> <div class="admin-jury-entry">
<input type="text" name="jury_lecteur_interne[]" <input type="text" name="jury_lecteur_interne[]"
value="<?= htmlspecialchars($lm['name']) ?>" placeholder="Nom" value="<?= htmlspecialchars($lm['name']) ?>" placeholder="Nom"
<?= $li === 0 ? 'required' : '' ?> <?= (!$adminMode && $li === 0) ? 'required' : '' ?>
aria-label="Lecteur·ice interne <?= $li + 1 ?> — nom"> aria-label="Lecteur·ice interne <?= $li + 1 ?> — nom">
<button type="button" class="btn btn--sm btn--ghost admin-btn-remove" <button type="button" class="btn btn--sm btn--ghost admin-btn-remove"
onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button> onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button>
@@ -116,11 +117,11 @@ if ($addMode && function_exists('old')) {
<!-- Lecteur·ice(s) externe --> <!-- Lecteur·ice(s) externe -->
<fieldset class="admin-jury-lecteurs"> <fieldset class="admin-jury-lecteurs">
<legend>Lecteur·ice(s) externe <span class="asterisk">*</span></legend> <legend>Lecteur·ice(s) externe<?= $adminMode ? '' : ' <span class="asterisk">*</span>' ?></legend>
<div id="jury-lecteurs-externes-list" class="admin-jury-list"> <div id="jury-lecteurs-externes-list" class="admin-jury-list">
<?php if (empty($lecteursExternes)): ?> <?php if (empty($lecteursExternes)): ?>
<div class="admin-jury-entry"> <div class="admin-jury-entry">
<input type="text" name="jury_lecteur_externe[]" placeholder="Nom" required <input type="text" name="jury_lecteur_externe[]" placeholder="Nom" <?= $adminMode ? '' : 'required' ?>
aria-label="Lecteur·ice externe 1 — nom"> aria-label="Lecteur·ice externe 1 — nom">
<button type="button" class="btn btn--sm btn--ghost admin-btn-remove" <button type="button" class="btn btn--sm btn--ghost admin-btn-remove"
onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button> onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button>
@@ -130,7 +131,7 @@ if ($addMode && function_exists('old')) {
<div class="admin-jury-entry"> <div class="admin-jury-entry">
<input type="text" name="jury_lecteur_externe[]" <input type="text" name="jury_lecteur_externe[]"
value="<?= htmlspecialchars($lm['name']) ?>" placeholder="Nom" value="<?= htmlspecialchars($lm['name']) ?>" placeholder="Nom"
<?= $li === 0 ? 'required' : '' ?> <?= (!$adminMode && $li === 0) ? 'required' : '' ?>
aria-label="Lecteur·ice externe <?= $li + 1 ?> — nom"> aria-label="Lecteur·ice externe <?= $li + 1 ?> — nom">
<button type="button" class="btn btn--sm btn--ghost admin-btn-remove" <button type="button" class="btn btn--sm btn--ghost admin-btn-remove"
onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button> onclick="removeJuryRow(this)" aria-label="Supprimer"><span aria-hidden="true">✕</span></button>

View File

@@ -49,6 +49,7 @@ deploy:
--exclude '.claude' \ --exclude '.claude' \
--exclude '.pi' \ --exclude '.pi' \
--exclude '.DS_Store' \ --exclude '.DS_Store' \
--exclude '.env' \
--exclude 'storage/xamxam.db' \ --exclude 'storage/xamxam.db' \
--exclude 'storage/theses' \ --exclude 'storage/theses' \
--exclude 'storage/covers' \ --exclude 'storage/covers' \
@@ -62,6 +63,44 @@ deploy:
app/ xamxam:/var/www/xamxam/ app/ xamxam:/var/www/xamxam/
ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}" 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" 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 <new_base64_key>
# 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 <new_base64_key>"
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')] [group('deploy')]
deploy-db: deploy-db:

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env php
<?php
/**
* Re-encrypt the SMTP password after rotating APP_KEY.
*
* Usage:
* php scripts/reencrypt-smtp-password.php <new_base64_key> [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 <new_base64_key> [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";