mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes
- tag-search: add minTags/required params, counter shows red if < 3, accent if ≥ 3 - form.php: pass minTags=3 for partage mode keywords - checkbox-list: support labelHtml for raw HTML label with targetable asterisk span - language-autre-fragment: OOB swap updates #languages-required-asterisk when autre pills change - language-search: client-side update #languages-required-asterisk on pill add/remove - contenus.php: replace 3 form+submit-button fieldsets with HTMX auto-save checkboxes - settings.php: detect HX-Request header, return OOB CSRF token updates, skip redirect
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -3,6 +3,9 @@
|
|||||||
- [x] Fix email addresses in about.php contacts section not using EmailObfuscator for link text
|
- [x] Fix email addresses in about.php contacts section not using EmailObfuscator for link text
|
||||||
- [x] Raise rate limits: SearchController 30→300, request-access 3→30, partage 5→50
|
- [x] Raise rate limits: SearchController 30→300, request-access 3→30, partage 5→50
|
||||||
- [x] Make Libre option toggleable in Degré d'ouverture fieldset, move to top, remove temporary note
|
- [x] Make Libre option toggleable in Degré d'ouverture fieldset, move to top, remove temporary note
|
||||||
|
- [x] Mots-clés required (min 3) in partage form: red count < 3, accent ≥ 3
|
||||||
|
- [x] Language checkbox-list no longer required when language_autre pill is present
|
||||||
|
- [x] Admin contenus: auto-save checkboxes via HTMX (Restrictions, Degré d'ouverture, Types de travaux), remove Enregistrer buttons
|
||||||
- [x] Improve recapitulatif.php (partage): bottom margin/padding, center .thanks-success
|
- [x] Improve recapitulatif.php (partage): bottom margin/padding, center .thanks-success
|
||||||
- [x] Display ALL submitted info in recapitulatif page + email recap
|
- [x] Display ALL submitted info in recapitulatif page + email recap
|
||||||
- [x] Add "validate your info / contact xamxam@erg.be" note on recap page
|
- [x] Add "validate your info / contact xamxam@erg.be" note on recap page
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ require_once APP_ROOT . '/src/AdminLogger.php';
|
|||||||
$db = new Database();
|
$db = new Database();
|
||||||
$logger = AdminLogger::make();
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
|
$isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true');
|
||||||
$section = $_POST['section'] ?? '';
|
$section = $_POST['section'] ?? '';
|
||||||
|
|
||||||
if ($section === 'formulaire') {
|
if ($section === 'formulaire') {
|
||||||
@@ -34,7 +35,9 @@ if ($section === 'formulaire') {
|
|||||||
$newValues[$key] = $value;
|
$newValues[$key] = $value;
|
||||||
}
|
}
|
||||||
$logger->logFormSettingsUpdate($newValues);
|
$logger->logFormSettingsUpdate($newValues);
|
||||||
|
if (!$isHxRequest) {
|
||||||
App::flash('success', "Paramètres du formulaire mis à jour.");
|
App::flash('success', "Paramètres du formulaire mis à jour.");
|
||||||
|
}
|
||||||
} elseif ($section === 'objet_types') {
|
} elseif ($section === 'objet_types') {
|
||||||
$newValues = [
|
$newValues = [
|
||||||
'objet_these_enabled' => isset($_POST['objet_these_enabled']) ? '1' : '0',
|
'objet_these_enabled' => isset($_POST['objet_these_enabled']) ? '1' : '0',
|
||||||
@@ -43,7 +46,9 @@ if ($section === 'formulaire') {
|
|||||||
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
|
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
|
||||||
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
|
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
|
||||||
$logger->logObjetTypesUpdate($newValues);
|
$logger->logObjetTypesUpdate($newValues);
|
||||||
|
if (!$isHxRequest) {
|
||||||
App::flash('success', "Types de travaux mis à jour.");
|
App::flash('success', "Types de travaux mis à jour.");
|
||||||
|
}
|
||||||
} elseif ($section === 'smtp') {
|
} elseif ($section === 'smtp') {
|
||||||
$smtpData = [
|
$smtpData = [
|
||||||
'host' => $_POST['smtp_host'] ?? '',
|
'host' => $_POST['smtp_host'] ?? '',
|
||||||
@@ -96,5 +101,15 @@ if ($section === 'formulaire') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
if ($isHxRequest) {
|
||||||
|
// Return updated CSRF tokens for all three hidden inputs on the page
|
||||||
|
$newToken = htmlspecialchars($_SESSION['csrf_token']);
|
||||||
|
echo '<input type="hidden" id="csrf_token_files" value="' . $newToken . '" hx-swap-oob="true">';
|
||||||
|
echo '<input type="hidden" id="csrf_token_acces" value="' . $newToken . '" hx-swap-oob="true">';
|
||||||
|
echo '<input type="hidden" id="csrf_token_types" value="' . $newToken . '" hx-swap-oob="true">';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
header('Location: /admin/parametres.php');
|
header('Location: /admin/parametres.php');
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -18,5 +18,16 @@ $selectedIds = isset($_POST['languages']) && is_array($_POST['languages'])
|
|||||||
? $_POST['languages']
|
? $_POST['languages']
|
||||||
: [];
|
: [];
|
||||||
$anyChecked = !empty($selectedIds);
|
$anyChecked = !empty($selectedIds);
|
||||||
|
|
||||||
|
// Also check if any "autre" language pills are present (posted as language_autre[])
|
||||||
|
$hasLangAutre = isset($_POST['language_autre']) && is_array($_POST['language_autre'])
|
||||||
|
&& count(array_filter($_POST['language_autre'], fn($l) => is_string($l) && trim($l) !== '')) > 0;
|
||||||
|
|
||||||
|
// The "Autre(s) langue(s)" label is required if no standard language is checked.
|
||||||
|
// The "Langue(s) du TFE" checkbox list is required if neither standard languages
|
||||||
|
// nor "autre" languages are set.
|
||||||
|
$langAutreRequired = !$anyChecked;
|
||||||
|
$checkboxesRequired = !$anyChecked && !$hasLangAutre;
|
||||||
?>
|
?>
|
||||||
<span id="language-autre-required"><?= !$anyChecked ? ' <span class="asterisk">*</span>' : '' ?></span>
|
<span id="language-autre-required"><?= $langAutreRequired ? ' <span class="asterisk">*</span>' : '' ?></span>
|
||||||
|
<span id="languages-required-asterisk" hx-swap-oob="true"><?= $checkboxesRequired ? ' <span class="asterisk">*</span>' : '' ?></span>
|
||||||
|
|||||||
@@ -545,6 +545,19 @@
|
|||||||
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||||
+\\\\\\\ to: nqmqrqmo dd511b0d "fix: obfuscate email in contact links, raise rate limits, make Libre toggleable" (rebased revision)
|
+\\\\\\\ to: nqmqrqmo dd511b0d "fix: obfuscate email in contact links, raise rate limits, make Libre toggleable" (rebased revision)
|
||||||
++ $linkName = $link['name'] ?? '';
|
++ $linkName = $link['name'] ?? '';
|
||||||
|
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: nqmqrqmo dd511b0d "fix: obfuscate email in contact links, raise rate limits, make Libre toggleable" (rebased revision)
|
||||||
|
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||||
|
- $linkName = $link['name'] ?? '';
|
||||||
|
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
|
||||||
|
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: olzzwmwr 277c8ce4 "feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes" (rebased revision)
|
||||||
|
$linkName = $link['name'] ?? '';
|
||||||
|
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||||
|
$linkLockedYear = $link['locked_year'] ?? null;
|
||||||
|
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||||
|
+\\\\\\\ to: olzzwmwr 82533c5a "feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes" (rebased revision)
|
||||||
|
++ $linkName = $link['name'] ?? '';
|
||||||
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||||
?>
|
?>
|
||||||
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
||||||
|
|||||||
@@ -88,34 +88,40 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Restrictions d'accès aux fichiers</legend>
|
<legend>Restrictions d'accès aux fichiers</legend>
|
||||||
|
|
||||||
<form method="post" action="actions/settings.php" class="param-form">
|
<div class="param-form">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" id="csrf_token_files" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="section" value="formulaire">
|
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="restricted_files_enabled" value="1"
|
<input type="checkbox" name="restricted_files_enabled" value="1"
|
||||||
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_files"
|
||||||
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Activer la restriction d'accès</strong><br>
|
<strong>Activer la restriction d'accès</strong><br>
|
||||||
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
|
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn--primary">Enregistrer</button>
|
|
||||||
</form>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Degré d'ouverture</legend>
|
<legend>Degré d'ouverture</legend>
|
||||||
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
|
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
|
||||||
|
|
||||||
<form method="post" action="actions/settings.php" class="param-form">
|
<div class="param-form">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" id="csrf_token_acces" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="section" value="formulaire">
|
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="access_type_libre_enabled" value="1"
|
<input type="checkbox" name="access_type_libre_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_acces"
|
||||||
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Libre</strong><br>
|
<strong>Libre</strong><br>
|
||||||
<small>Libre accès — TFE accessible publiquement sur la plateforme et en bibliothèque</small>
|
<small>Libre accès — TFE accessible publiquement sur la plateforme et en bibliothèque</small>
|
||||||
@@ -124,7 +130,12 @@
|
|||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="access_type_interne_enabled" value="1"
|
<input type="checkbox" name="access_type_interne_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_acces"
|
||||||
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Interne</strong><br>
|
<strong>Interne</strong><br>
|
||||||
<small>TFE accessible uniquement sur place en physique</small>
|
<small>TFE accessible uniquement sur place en physique</small>
|
||||||
@@ -133,15 +144,18 @@
|
|||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="access_type_interdit_enabled" value="1"
|
<input type="checkbox" name="access_type_interdit_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_acces"
|
||||||
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Interdit</strong><br>
|
<strong>Interdit</strong><br>
|
||||||
<small>TFE non disponible en physique ni sur le site</small>
|
<small>TFE non disponible en physique ni sur le site</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn--primary">Enregistrer</button>
|
|
||||||
</form>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -149,9 +163,8 @@
|
|||||||
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
|
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
|
||||||
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
|
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
|
||||||
|
|
||||||
<form method="post" action="actions/settings.php" class="param-form">
|
<div class="param-form">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" id="csrf_token_types" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="section" value="objet_types">
|
|
||||||
|
|
||||||
<label class="param-checkbox param-checkbox--disabled">
|
<label class="param-checkbox param-checkbox--disabled">
|
||||||
<input type="checkbox" disabled checked>
|
<input type="checkbox" disabled checked>
|
||||||
@@ -163,7 +176,12 @@
|
|||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="objet_these_enabled" value="1"
|
<input type="checkbox" name="objet_these_enabled" value="1"
|
||||||
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_types"
|
||||||
|
hx-vals='{"section":"objet_types"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Thèse</strong><br>
|
<strong>Thèse</strong><br>
|
||||||
<small>Thèses doctorales</small>
|
<small>Thèses doctorales</small>
|
||||||
@@ -172,15 +190,18 @@
|
|||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
<input type="checkbox" name="objet_frart_enabled" value="1"
|
<input type="checkbox" name="objet_frart_enabled" value="1"
|
||||||
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
|
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
|
hx-post="/admin/actions/settings.php"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-include="#csrf_token_types"
|
||||||
|
hx-vals='{"section":"objet_types"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Frart</strong><br>
|
<strong>Frart</strong><br>
|
||||||
<small>Formation de recherche en art</small>
|
<small>Formation de recherche en art</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn--primary">Enregistrer</button>
|
|
||||||
</form>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
*
|
*
|
||||||
* Variables consumed:
|
* Variables consumed:
|
||||||
* string $name — input name attribute (will be posted as array: name[])
|
* string $name — input name attribute (will be posted as array: name[])
|
||||||
* string $label — group label text
|
* string $label — group label text (htmlspecialchars'd unless $labelHtml is set)
|
||||||
|
* string $labelHtml — raw HTML label override (bypasses htmlspecialchars, optional)
|
||||||
* array $options — each element must have 'id' and 'name' keys
|
* array $options — each element must have 'id' and 'name' keys
|
||||||
* array $checked — array of 'id' values that are currently checked
|
* array $checked — array of 'id' values that are currently checked
|
||||||
* bool $required — whether at least one checkbox must be checked; default false
|
* bool $required — whether at least one checkbox must be checked; default false
|
||||||
@@ -27,7 +28,7 @@ $hxSwap = $hxSwap ?? 'outerHTML';
|
|||||||
$hxInclude = $hxInclude ?? 'this';
|
$hxInclude = $hxInclude ?? 'this';
|
||||||
?>
|
?>
|
||||||
<div>
|
<div>
|
||||||
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
|
<span class="admin-row-label"><?= isset($labelHtml) ? $labelHtml : (htmlspecialchars($label) . ($required ? ' <span class="asterisk">*</span>' : '')) ?></span>
|
||||||
<fieldset class="admin-checkbox-group"
|
<fieldset class="admin-checkbox-group"
|
||||||
<?= $required ? ' required aria-required="true"' : '' ?>
|
<?= $required ? ' required aria-required="true"' : '' ?>
|
||||||
<?php if ($hxPost !== ''): ?>
|
<?php if ($hxPost !== ''): ?>
|
||||||
|
|||||||
@@ -193,12 +193,14 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
|||||||
$label = "Langue(s) du TFE :";
|
$label = "Langue(s) du TFE :";
|
||||||
$options = $languages;
|
$options = $languages;
|
||||||
$checked = $formData["languages"] ?? [];
|
$checked = $formData["languages"] ?? [];
|
||||||
$required = !$adminMode;
|
$_hasLangAutre = !empty($formData['language_autre']) && is_array($formData['language_autre']) && count(array_filter($formData['language_autre'], fn($l) => is_string($l) && trim($l) !== '')) > 0;
|
||||||
|
$required = !$adminMode && !$_hasLangAutre;
|
||||||
$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-required";
|
$hxTarget = "#languages-required-asterisk";
|
||||||
$hxSwap = "outerHTML";
|
$hxSwap = "outerHTML";
|
||||||
|
$labelHtml = htmlspecialchars($label) . '<span id="languages-required-asterisk">' . ($required ? ' <span class="asterisk">*</span>' : '') . '</span>';
|
||||||
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
|
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
|
||||||
unset($hxSwap);
|
unset($hxSwap, $_hasLangAutre, $labelHtml);
|
||||||
?>
|
?>
|
||||||
<?php
|
<?php
|
||||||
$_langAutreRequired = empty($formData["languages"]);
|
$_langAutreRequired = empty($formData["languages"]);
|
||||||
@@ -274,9 +276,11 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
|||||||
$placeholder = "Rechercher un mot-clé…";
|
$placeholder = "Rechercher un mot-clé…";
|
||||||
$hint = "Tapez pour rechercher ou créer des mots-clés.";
|
$hint = "Tapez pour rechercher ou créer des mots-clés.";
|
||||||
$selectedTags = $_selectedTags;
|
$selectedTags = $_selectedTags;
|
||||||
|
$required = !$adminMode;
|
||||||
|
$minTags = ($mode === 'partage') ? 3 : 0;
|
||||||
$hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php";
|
$hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php";
|
||||||
include APP_ROOT . "/templates/partials/form/tag-search.php";
|
include APP_ROOT . "/templates/partials/form/tag-search.php";
|
||||||
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost);
|
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost, $minTags, $required);
|
||||||
?>
|
?>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ $langCount = count($selectedLanguages);
|
|||||||
if (countEl) countEl.textContent = n + '/' + maxLanguages;
|
if (countEl) countEl.textContent = n + '/' + maxLanguages;
|
||||||
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
||||||
|
|
||||||
|
// Toggle the checkbox-list asterisk: if any "autre" language pill
|
||||||
|
// is present, the checkbox list is no longer required.
|
||||||
|
const asteriskEl = document.getElementById('languages-required-asterisk');
|
||||||
|
if (asteriskEl) {
|
||||||
|
const checkboxes = document.querySelectorAll('#languages-fieldset input[type="checkbox"]:checked');
|
||||||
|
asteriskEl.innerHTML = (n === 0 && checkboxes.length === 0) ? ' <span class="asterisk">*</span>' : '';
|
||||||
|
}
|
||||||
|
|
||||||
// Show/hide search input based on max
|
// Show/hide search input based on max
|
||||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||||
const maxMsg = container.querySelector('.tag-search-max-msg');
|
const maxMsg = container.querySelector('.tag-search-max-msg');
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* array $selectedTags — array of ['id' => int|null, 'name' => string] for pre-filled tags
|
* array $selectedTags — array of ['id' => int|null, 'name' => string] for pre-filled tags
|
||||||
* string|null $id — override the id attribute prefix
|
* string|null $id — override the id attribute prefix
|
||||||
* int $maxTags — maximum number of tags (default 10)
|
* int $maxTags — maximum number of tags (default 10)
|
||||||
|
* int $minTags — minimum required tags (default 0, no requirement)
|
||||||
|
* bool $required — whether minTags enforcement applies (default false)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$name = $name ?? 'tag';
|
$name = $name ?? 'tag';
|
||||||
@@ -27,10 +29,13 @@ $hxPost = $hxPost ?? '/admin/tag-search-fragment.php';
|
|||||||
$selectedTags = $selectedTags ?? [];
|
$selectedTags = $selectedTags ?? [];
|
||||||
$id = $id ?? $name;
|
$id = $id ?? $name;
|
||||||
$maxTags = $maxTags ?? 10;
|
$maxTags = $maxTags ?? 10;
|
||||||
|
$minTags = $minTags ?? 0;
|
||||||
|
$required = $required ?? false;
|
||||||
$tagCount = count($selectedTags);
|
$tagCount = count($selectedTags);
|
||||||
|
$belowMin = $required && $tagCount < $minTags;
|
||||||
?>
|
?>
|
||||||
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
||||||
<span class="admin-row-label"><?= htmlspecialchars($label) ?></span>
|
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
|
||||||
<div class="tag-search-wrapper">
|
<div class="tag-search-wrapper">
|
||||||
<?php if ($hint): ?>
|
<?php if ($hint): ?>
|
||||||
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
||||||
@@ -49,9 +54,9 @@ $tagCount = count($selectedTags);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Counter visible only when tags exist or max reached -->
|
<!-- Counter visible only when tags exist or max reached, or min requirement active -->
|
||||||
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $tagCount === 0 ? ' style="display:none"' : '' ?>>
|
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= ($tagCount === 0 && !$required) ? ' style="display:none"' : '' ?>>
|
||||||
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $tagCount ?>/<?= (int)$maxTags ?></span>
|
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"<?= $belowMin ? ' style="color:var(--text-danger)"' : ($tagCount >= $minTags && $required ? ' style="color:var(--accent)"' : '') ?>><?= $tagCount ?>/<?= (int)$maxTags ?><?= $required ? ' (min ' . (int)$minTags . ')' : '' ?></span>
|
||||||
<?php if ($tagCount >= $maxTags): ?>
|
<?php if ($tagCount >= $maxTags): ?>
|
||||||
<span class="tag-search-max-msg">Maximum de mots-clés atteint</span>
|
<span class="tag-search-max-msg">Maximum de mots-clés atteint</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -90,13 +95,23 @@ $tagCount = count($selectedTags);
|
|||||||
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
||||||
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
||||||
const maxTags = <?= (int)$maxTags ?>;
|
const maxTags = <?= (int)$maxTags ?>;
|
||||||
|
const minTags = <?= (int)$minTags ?>;
|
||||||
|
const required = <?= json_encode($required) ?>;
|
||||||
const inputName = <?= json_encode($name) ?>;
|
const inputName = <?= json_encode($name) ?>;
|
||||||
let selectedIdx = -1;
|
let selectedIdx = -1;
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
const n = pills.querySelectorAll('.tag-pill').length;
|
const n = pills.querySelectorAll('.tag-pill').length;
|
||||||
if (countEl) countEl.textContent = n + '/' + maxTags;
|
const suffix = required ? ' (min ' + minTags + ')' : '';
|
||||||
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
|
||||||
|
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
|
||||||
|
if (countEl && required) {
|
||||||
|
if (n < minTags) {
|
||||||
|
countEl.style.color = 'var(--text-danger)';
|
||||||
|
} else {
|
||||||
|
countEl.style.color = 'var(--accent)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show/hide search input based on max
|
// Show/hide search input based on max
|
||||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||||
|
|||||||
198
backup-plan.md
Normal file
198
backup-plan.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# SQLite Backup & Data Integrity Plan
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
- `[ ]` To do
|
||||||
|
- `[x]` Done
|
||||||
|
- `[~]` Partial / needs review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — WAL Mode
|
||||||
|
|
||||||
|
**Goal:** Ensure SQLite uses Write-Ahead Logging for safe concurrent reads and hot backups.
|
||||||
|
|
||||||
|
- [ ] Connect to the DB and verify WAL is active:
|
||||||
|
```bash
|
||||||
|
sqlite3 /path/to/your.db "PRAGMA journal_mode;"
|
||||||
|
# Expected output: wal
|
||||||
|
```
|
||||||
|
- [ ] If not `wal`, enable it (run once, persists):
|
||||||
|
```bash
|
||||||
|
sqlite3 /path/to/your.db "PRAGMA journal_mode=WAL;"
|
||||||
|
```
|
||||||
|
- [ ] Confirm the `-wal` and `-shm` sidecar files exist next to the `.db` file after a write
|
||||||
|
- [ ] Make sure nginx/PHP has write access to those sidecar files (same owner as the `.db`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Audit Log
|
||||||
|
|
||||||
|
**Goal:** Record every INSERT, UPDATE, and DELETE with the actor, timestamp, and a before/after snapshot.
|
||||||
|
|
||||||
|
### 2.1 — Create the table
|
||||||
|
|
||||||
|
- [ ] Add the `audit_log` table to the DB:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK(action IN ('INSERT','UPDATE','DELETE')),
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
record_id INTEGER,
|
||||||
|
old_data TEXT,
|
||||||
|
new_data TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 — Instrument PHP mutations
|
||||||
|
|
||||||
|
- [ ] Create a reusable `audit()` helper in PHP that accepts `$db, $actor, $action, $table, $id, $old, $new`
|
||||||
|
- [ ] Wrap every **DELETE** in the admin dashboard with `audit()`, capturing the row before deletion
|
||||||
|
- [ ] Wrap every **UPDATE** (form submissions + admin edits) with `audit()`, capturing before/after
|
||||||
|
- [ ] Wrap **INSERTs** for completeness (new_data only)
|
||||||
|
- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5;`
|
||||||
|
|
||||||
|
### 2.3 — Protect the audit log
|
||||||
|
|
||||||
|
- [ ] No UI should expose a "clear audit log" button
|
||||||
|
- [ ] The PHP DB user should not have `DELETE` permission on `audit_log` (use a restricted PDO connection for app queries if possible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Soft Deletes
|
||||||
|
|
||||||
|
**Goal:** Prevent hard DELETEs on critical tables so data is always recoverable instantly. htmx elements that query languages/keywords must continue to work transparently.
|
||||||
|
|
||||||
|
### 3.1 — Schema changes
|
||||||
|
|
||||||
|
- [ ] Identify all tables that htmx elements query (e.g. `languages`, `keywords`, any lookup/reference tables)
|
||||||
|
- [ ] Add `deleted_at` to each:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE languages ADD COLUMN deleted_at TEXT DEFAULT NULL;
|
||||||
|
ALTER TABLE keywords ADD COLUMN deleted_at TEXT DEFAULT NULL;
|
||||||
|
-- repeat for other affected tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 — Replace DELETE queries
|
||||||
|
|
||||||
|
- [ ] Search the codebase for `DELETE FROM languages`, `DELETE FROM keywords`, etc.
|
||||||
|
- [ ] Replace each hard DELETE with a soft delete:
|
||||||
|
```php
|
||||||
|
// Before
|
||||||
|
$db->prepare("DELETE FROM languages WHERE id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
// After
|
||||||
|
$db->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")
|
||||||
|
->execute([$id]);
|
||||||
|
```
|
||||||
|
- [ ] Do the same in any admin dashboard bulk-delete operations
|
||||||
|
|
||||||
|
### 3.3 — Filter deleted rows everywhere
|
||||||
|
|
||||||
|
- [ ] Add `WHERE deleted_at IS NULL` to **every** SELECT that feeds an htmx endpoint:
|
||||||
|
```sql
|
||||||
|
-- Example
|
||||||
|
SELECT * FROM languages WHERE deleted_at IS NULL ORDER BY name;
|
||||||
|
SELECT * FROM keywords WHERE deleted_at IS NULL ORDER BY name;
|
||||||
|
```
|
||||||
|
- [ ] Search for raw `SELECT * FROM languages` and `SELECT * FROM keywords` across all PHP files and patch each one
|
||||||
|
- [ ] Test each htmx-driven element (dropdowns, tag lists, autocompletes) to confirm deleted entries no longer appear
|
||||||
|
|
||||||
|
### 3.4 — Admin: show soft-deleted entries
|
||||||
|
|
||||||
|
- [ ] Add an admin view that lists soft-deleted rows (`WHERE deleted_at IS NOT NULL`) with a **Restore** button
|
||||||
|
- [ ] The restore action sets `deleted_at = NULL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Hourly Snapshots via Cronjob
|
||||||
|
|
||||||
|
**Goal:** Automatically save compressed, timestamped copies of the DB locally, retained for 30 days.
|
||||||
|
|
||||||
|
### 4.1 — Create the backup script
|
||||||
|
|
||||||
|
- [ ] Create `/usr/local/bin/backup-sqlite.sh`:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DB_PATH="/var/www/myapp/database.db"
|
||||||
|
BACKUP_DIR="/var/backups/myapp"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||||
|
TIMESTAMP=$(date +"%Y-%m-%dT%H-%M-%S")
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/db-$TIMESTAMP.db.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Safe hot backup using SQLite's online backup API
|
||||||
|
sqlite3 "$DB_PATH" ".backup /tmp/myapp-snapshot.db"
|
||||||
|
gzip -c /tmp/myapp-snapshot.db > "$BACKUP_FILE"
|
||||||
|
rm /tmp/myapp-snapshot.db
|
||||||
|
|
||||||
|
# Prune old backups
|
||||||
|
find "$BACKUP_DIR" -name "*.db.gz" -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
echo "[$(date)] Backup written: $BACKUP_FILE"
|
||||||
|
```
|
||||||
|
- [ ] Make it executable:
|
||||||
|
```bash
|
||||||
|
chmod +x /usr/local/bin/backup-sqlite.sh
|
||||||
|
```
|
||||||
|
- [ ] Run it manually once and verify a `.db.gz` file appears in `/var/backups/myapp/`
|
||||||
|
- [ ] Test restore by decompressing and opening the snapshot:
|
||||||
|
```bash
|
||||||
|
gunzip -c /var/backups/myapp/db-<timestamp>.db.gz > /tmp/test-restore.db
|
||||||
|
sqlite3 /tmp/test-restore.db ".tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 — Schedule with cron
|
||||||
|
|
||||||
|
- [ ] Open the crontab:
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
- [ ] Add hourly and daily jobs:
|
||||||
|
```cron
|
||||||
|
# Hourly snapshot — kept 30 days
|
||||||
|
0 * * * * /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
||||||
|
|
||||||
|
# Daily snapshot at 2am — kept 90 days
|
||||||
|
0 2 * * * RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
||||||
|
```
|
||||||
|
- [ ] Verify the log after the next hour: `tail -f /var/log/sqlite-backup.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Remote Sync *(for later)*
|
||||||
|
|
||||||
|
**Goal:** Push backups off the VM to a remote destination so a disk failure or VM loss doesn't take your history with it.
|
||||||
|
|
||||||
|
- [ ] Choose a remote destination (Backblaze B2, S3, SFTP, etc.)
|
||||||
|
- [ ] Install and configure rclone:
|
||||||
|
```bash
|
||||||
|
apt install rclone
|
||||||
|
rclone config # set up a remote, name it "mybackups"
|
||||||
|
```
|
||||||
|
- [ ] Add remote sync to the backup script after the `gzip` step:
|
||||||
|
```bash
|
||||||
|
rclone copy "$BACKUP_FILE" mybackups:myapp-backups/
|
||||||
|
```
|
||||||
|
- [ ] Enable versioning on the remote bucket (B2/S3) so even remote overwrites are recoverable
|
||||||
|
- [ ] Test a full restore from remote:
|
||||||
|
```bash
|
||||||
|
rclone copy mybackups:myapp-backups/db-<timestamp>.db.gz /tmp/
|
||||||
|
gunzip /tmp/db-<timestamp>.db.gz
|
||||||
|
sqlite3 /tmp/db-<timestamp>.db ".tables"
|
||||||
|
```
|
||||||
|
- [ ] (Optional) Set up a separate cron to prune remote copies older than 6 months
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference — Recovery Scenarios
|
||||||
|
|
||||||
|
| Scenario | Solution |
|
||||||
|
|---|---|
|
||||||
|
| Admin accidentally deleted a row | Set `deleted_at = NULL` in the relevant table |
|
||||||
|
| User submitted bad data via a form | Query `audit_log` for the `old_data` JSON, restore manually |
|
||||||
|
| Bulk accidental delete | Restore from the last hourly snapshot (< 1h data loss max) |
|
||||||
|
| VM or disk failure | Pull latest snapshot from remote (Phase 5) |
|
||||||
|
| "Who deleted this and when?" | `SELECT * FROM audit_log WHERE table_name='x' AND action='DELETE'` |
|
||||||
Reference in New Issue
Block a user