Reintroduce TFE duration metadata: DB columns, form fields, controllers, views, and migration

Add 'unsafe-eval' to CSP script-src directives (htmx requires Function())
This commit is contained in:
Pontoporeia
2026-06-11 13:05:37 +02:00
parent 00fed5f0e3
commit d588ae004d
81 changed files with 1061 additions and 840 deletions

View File

@@ -0,0 +1,80 @@
-- 040_duration_fields.sql
-- Reintroduce duration metadata (value + unit) for TFE records.
-- These columns were removed in 024_remove_duration_size_fields.sql but are now
-- re-added with a cleaner design: a numeric value and a unit selector.
-- Add new columns to theses table
ALTER TABLE theses ADD COLUMN duration_value REAL;
ALTER TABLE theses ADD COLUMN duration_unit TEXT DEFAULT 'pages';
-- Drop and recreate views to include the new columns
DROP VIEW IF EXISTS v_theses_full;
DROP VIEW IF EXISTS v_theses_public;
CREATE VIEW IF NOT EXISTS v_theses_full AS
SELECT
t.id,
t.identifier,
t.title,
t.subtitle,
t.year,
t.is_doctoral,
t.objet,
o.name as orientation,
ap.name as ap_program,
ft.name as finality_type,
t.synopsis,
t.context_note,
t.duration_value,
t.duration_unit,
at.name as access_type,
lt.name as license_type,
t.license_id,
t.license_custom,
t.access_type_id,
t.jury_points,
t.submitted_at,
t.defense_date,
t.published_at,
t.is_published,
t.baiu_link,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r,
t.remarks,
t.jury_note_added,
t.contact_visible,
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
GROUP_CONCAT(DISTINCT s.name) as supervisors,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes,
GROUP_CONCAT(DISTINCT l.name) as languages,
GROUP_CONCAT(DISTINCT fmt.name) as formats,
GROUP_CONCAT(DISTINCT tg.name) as keywords,
-- First author's email and contact-visibility flag
(SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email,
(SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN finality_types ft ON t.finality_id = ft.id
LEFT JOIN access_types at ON t.access_type_id = at.id
LEFT JOIN license_types lt ON t.license_id = lt.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id
LEFT JOIN languages l ON tl.language_id = l.id
LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id
LEFT JOIN format_types fmt ON tf.format_id = fmt.id
LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id
LEFT JOIN tags tg ON tt.tag_id = tg.id
GROUP BY t.id;
CREATE VIEW IF NOT EXISTS v_theses_public AS
SELECT * FROM v_theses_full
WHERE is_published = 1;

View File

@@ -7,8 +7,6 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
error_log('[acces-etudiante.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
@@ -90,6 +88,15 @@ switch ($action) {
}
break;
case 'delete':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/acces.php', success: 'Lien supprimé définitivement.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
}
break;
case 'update':
if ($id > 0) {
$name = isset($_POST['name']) ? trim($_POST['name']) : null;

View File

@@ -6,7 +6,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[access-request.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | request_id=' . ($_POST['request_id'] ?? 0) . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])

View File

@@ -11,8 +11,6 @@ require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.php';
error_log('[account.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'change_password') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
// ── CSRF ──────────────────────────────────────────────────────────────────────

View File

@@ -8,7 +8,6 @@
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[corbeille.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none'));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[delete.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';

View File

@@ -0,0 +1,93 @@
<?php
/**
* Admin autosave draft endpoint.
*
* POST — receive all form fields and persist them to the session draft store.
*
* Drafts are scoped per mode:
* - add: keyed by a generated token (stored in form)
* - edit: keyed by thesis_id
*
* Excluded field patterns (not persisted as drafts):
* - csrf_token
* - FilePond metadata (filepond_mode, queue_file, filepond_*)
* - Files-related fields
* - Empty values
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once APP_ROOT . '/src/AdminAuth.php';
AdminAuth::requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
// ── CSRF check ──────────────────────────────────────────────────────────
if ($method !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['error' => 'Méthode non autorisée.']);
exit;
}
if (
!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Token de sécurité invalide.']);
exit;
}
// ── Determine draft key ─────────────────────────────────────────────────
$draftToken = $_POST['draft_token'] ?? '';
$thesisId = (int)($_POST['thesis_id'] ?? 0);
if ($draftToken !== '' && preg_match('/^[a-f0-9]{16}$/', $draftToken)) {
$draftKey = 'admin_draft_' . $draftToken;
} elseif ($thesisId > 0) {
$draftKey = 'admin_draft_edit_' . $thesisId;
} else {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Paramètres invalides.']);
exit;
}
// ── Save all form fields ────────────────────────────────────────────────
$excludePrefixes = [
'csrf_token', 'share_link_token',
'filepond_mode', 'queue_file', 'filepond_',
];
$excludeExact = ['draft_token', 'thesis_id', 'slug',
'couverture', 'note_intention', 'files', 'annexes',
'peertube_video', 'peertube_audio', 'cover_remove',
'go', 'MAX_FILE_SIZE'];
$draft = [];
foreach ($_POST as $key => $value) {
if (in_array($key, $excludeExact, true)) continue;
$skip = false;
foreach ($excludePrefixes as $prefix) {
if (str_starts_with($key, $prefix)) { $skip = true; break; }
}
if ($skip) continue;
if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) {
continue;
}
$draft[$key] = $value;
}
$_SESSION[$draftKey] = $draft;
// Rotate CSRF after mutation
$newToken = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $newToken;
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'csrf_token' => $newToken,
]);
exit;

View File

@@ -3,8 +3,6 @@
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[edit.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
@@ -37,6 +35,10 @@ try {
// Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Clear autosave draft + FilePond temp files
unset($_SESSION['admin_draft_edit_' . $thesisId]);
unset($_SESSION['filepond_tmp']);
AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
App::flash('success', "TFE mis à jour avec succès!");

View File

@@ -24,7 +24,6 @@ function relinkError(int $code, string $message): never {
// CSRF via header
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
error_log('[relink] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | csrf=' . (isset($_SESSION['csrf_token']) ? 'set' : 'missing') . ' | header=' . (strlen($csrfHeader) > 0 ? substr($csrfHeader, 0, 8) . '...' : 'empty') . ' | body_len=' . strlen(file_get_contents('php://input')));
if (!isset($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
relinkError(403, 'Token CSRF invalide.');

View File

@@ -6,7 +6,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -3,8 +3,6 @@
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[formulaire.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
// Only suppress display_errors in production (cli-server = dev mode).
if (php_sapi_name() !== 'cli-server') {
ini_set('display_errors', 0);
@@ -25,8 +23,6 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
}
error_log('[formulaire.php] full FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/AdminLogger.php';
@@ -45,6 +41,10 @@ try {
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
$adminLogger->logAdd($thesisId, $identifier, $authorName);
// Clear autosave draft + FilePond temp files
unset($_SESSION['admin_draft_' . ($_POST['draft_token'] ?? '')]);
unset($_SESSION['admin_draft_add_token']);
unset($_SESSION['filepond_tmp']);
unset($_SESSION['csrf_token']);
$redirect = '../recapitulatif.php?id=' . $thesisId;

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[language.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[maintenance.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -5,7 +5,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[page.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | slug=' . ($_POST['slug'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[publish.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';

View File

@@ -1,12 +1,10 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[settings.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | section=' . ($_POST['section'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log('[settings.php] CSRF FAIL | session_token=' . ($_SESSION['csrf_token'] ?? 'none') . ' | post_token=' . ($_POST['csrf_token'] ?? 'none'));
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/parametres.php');
exit;
@@ -21,7 +19,6 @@ $logger = AdminLogger::make();
$isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true');
$section = $_POST['section'] ?? '';
error_log('[settings.php] PROCESS | section=' . $section . ' | post_keys=' . implode(',', array_keys($_POST)));
/**
* Return an HTML toast fragment for HTMX responses and exit.
@@ -58,9 +55,7 @@ function hxToastError(string $message): never {
if ($section === 'formulaire_restrictions') {
// HTMX may not send unchecked checkboxes even with hidden 0-value inputs;
// missing key means unchecked → treat as '0'.
$rawPost = $_POST['restricted_files_enabled'] ?? '(missing)';
$newValue = empty($_POST['restricted_files_enabled']) ? '0' : '1';
error_log('[settings.php] SAVE formulaire_restrictions | restricted_files_enabled raw=' . var_export($rawPost, true) . ' | resolved=' . $newValue);
$db->setSetting('restricted_files_enabled', $newValue);
$logger->logFormSettingsUpdate(['restricted_files_enabled' => $newValue]);
if ($isHxRequest) {
@@ -76,9 +71,7 @@ if ($section === 'formulaire_restrictions') {
];
$newValues = [];
foreach ($allowed as $key) {
$raw = $_POST[$key] ?? '(missing)';
$value = empty($_POST[$key]) ? '0' : '1';
error_log('[settings.php] SAVE formulaire_acces | ' . $key . ' raw=' . var_export($raw, true) . ' | resolved=' . $value);
$db->setSetting($key, $value);
$newValues[$key] = $value;
}
@@ -89,14 +82,10 @@ if ($section === 'formulaire_restrictions') {
App::flash('success', "Degrés d'ouverture mis à jour.");
}
} elseif ($section === 'objet_types') {
$rawThese = $_POST['objet_these_enabled'] ?? '(missing)';
$rawFrart = $_POST['objet_frart_enabled'] ?? '(missing)';
$newValues = [
'objet_these_enabled' => empty($_POST['objet_these_enabled']) ? '0' : '1',
'objet_frart_enabled' => empty($_POST['objet_frart_enabled']) ? '0' : '1',
];
error_log('[settings.php] SAVE objet_types | objet_these_enabled raw=' . var_export($rawThese, true) . ' | resolved=' . $newValues['objet_these_enabled']);
error_log('[settings.php] SAVE objet_types | objet_frart_enabled raw=' . var_export($rawFrart, true) . ' | resolved=' . $newValues['objet_frart_enabled']);
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
$logger->logObjetTypesUpdate($newValues);

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[smtp-test.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | test_email=' . ($_POST['test_email'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Database.php';

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[tag.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[visibility.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])

View File

@@ -83,35 +83,33 @@ $extraJsInline = '';
if ($editType === 'page' || $editType === 'about_page') {
$initialContent = $page["content"] ?? "";
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
$extraJs = ["/assets/js/vendor/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
minHeight: '100%',
spellcheck: false,
toolbar: true,
onChange: function(value) {
hidden.value = value;
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
}
});
JS;
} elseif ($editType === 'form_help') {
$initialContent = $formHelpContent;
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
$extraJs = ["/assets/js/vendor/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
minHeight: '100%',
spellcheck: false,
toolbar: true,
onChange: function(value) {
hidden.value = value;
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
}
});
JS;

View File

@@ -123,12 +123,12 @@ extract(FormBootstrap::adminFormVariables(
mode: 'edit',
formAction: '/admin/actions/edit.php',
hiddenFields:
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">'
. '<input type="hidden" name="thesis_id" value="' . $thesisId . '">',
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">',
formData: $formData,
siteSettings: $siteSettings,
helpBlocks: $helpBlocks,
options: [
'thesisId' => $thesisId,
'filesMode' => 'edit',
'existingWebsiteUrl' => $existingWebsiteUrl,
'existingWebsiteLabel' => $existingWebsiteLabel,
@@ -144,6 +144,8 @@ extract(FormBootstrap::adminFormVariables(
'currentFiles' => $currentFiles ?? [],
'currentContextNote' => $currentContextNote ?? null,
'currentContactVisible' => $currentContactVisible ?? null,
'currentDurationValue' => $currentDurationValue ?? null,
'currentDurationUnit' => $currentDurationUnit ?? 'pages',
'contactInterne' => $contactInterne ?? null,
'contactPublic' => $contactPublic ?? false,
'showCoverPreview' => true,
@@ -158,6 +160,10 @@ $formData['license_id'] = $currentLicenseId;
$formData['license_custom'] = $currentRaw['license_custom'] ?? '';
$formData['cc2r'] = $currentRaw['cc2r'] ?? false;
// Duration variables for the form template
$durationValue = $currentDurationValue ?? null;
$durationUnit = $currentDurationUnit ?? 'pages';
// Asset arrays and page chrome
$isAdmin = true;
$bodyClass = 'admin-body';

View File

@@ -13,8 +13,6 @@ AdminAuth::requireLogin();
$storageRoot = STORAGE_ROOT;
error_log('[file-browser] ENTRY | dir=' . ($_GET['dir'] ?? '(root)') . ' | storageRoot=' . $storageRoot);
// Determine which directory to browse
$relDir = trim($_GET['dir'] ?? '', '/');
if ($relDir !== '' && !preg_match('#^(tfe|these|frart|documents|theses)(/|$)#', $relDir)) {

View File

@@ -17,7 +17,6 @@ $importResults = [];
$importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
error_log('[admin/index.php] IMPORT ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | csv_file_error=' . ($_FILES['csv_file']['error'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide.";
} else {

View File

@@ -2,8 +2,6 @@
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
error_log('[admin/login.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_auth=' . (AdminAuth::isAuthenticated() ? '1' : '0') . ' | has_password=' . (AdminAuth::hasPassword() ? '1' : '0'));
if (!AdminAuth::hasPassword()) {
header('Location: /admin/');
exit;
@@ -24,8 +22,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$pageTitle = 'Connexion';
$isAdmin = true; $bodyClass = 'admin-body';
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/login.php';
require_once APP_ROOT . '/templates/admin/footer.php';
// Login page does not render the admin footer (no toast-region poll, no HTMX extras).
// It closes <html> directly so there is no dangling HTMX polling the toast endpoint
// while unauthenticated.
echo "\n</body>\n</html>";

View File

@@ -24,7 +24,10 @@ if (isset($_GET['id'])) {
if ($thesisId !== false && $thesisId > 0) {
try {
$db = new Database();
$thesis = $db->getThesis($thesisId);
// Student-mode preview: only show published theses.
$thesis = $studentMode
? $db->getThesisById($thesisId)
: $db->getThesis($thesisId);
if (!$thesis) {
$error = "TFE non trouvé.";

View File

@@ -9,7 +9,13 @@
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
// Don't redirect unauthenticated requests — just return empty (defense-in-depth).
// The toast-region poll fires on <hx-trigger="load">; if the user is on the
// login page they are not authenticated yet.
if (!AdminAuth::isAuthenticated()) {
http_response_code(204);
exit;
}
$flash = App::consumeFlash();

View File

@@ -541,7 +541,7 @@ th.admin-ap-col {
.admin-body main > section[aria-labelledby^="settings-"] fieldset legend {
padding: 0;
font-weight: 600;
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
@@ -1303,7 +1303,7 @@ th.admin-ap-col {
flex: 1 1 260px;
}
.param-smtp-test-row label {
font-weight: 600;
font-weight: 400;
margin-bottom: var(--space-2xs);
color: var(--text-secondary);
}
@@ -1909,7 +1909,7 @@ th.admin-ap-col {
.fhb-edit-label {
display: block;
font-size: var(--step--2);
font-weight: 500;
font-weight: 400;
color: var(--text-primary);
margin-bottom: var(--space-3xs);
}

View File

@@ -13,23 +13,32 @@ details > summary::-webkit-details-marker {
}
details {
padding: var(--space-s);
border: 1px solid var(--border-color);
border-radius: var(--radius, 6px);
background: var(--bg-secondary, var(--surface));
overflow: hidden;
}
details[open] {
padding-bottom: var(--space-s);
}
details > :not(summary) {
margin-left: var(--space-s);
margin-right: var(--space-s);
}
summary {
font-family: var(--font-display);
font-weight: 600;
text-decoration: none;
color: var(--accent-secondary);
transition: color 0.15s;
svg {
fill: var(--accent-secondary);
vertical-align: text-bottom;
height: 1.4em;
}
color: var(--text-primary);
transition: color 0.15s, background 0.15s;
padding: var(--space-s);
cursor: pointer;
}
summary:hover {
color: var(--accent-primary);
background: var(--hover-bg, rgba(0, 0, 0, 0.03));
}

View File

@@ -9,6 +9,7 @@
label {
display: block;
margin-bottom: var(--space-3xs);
font-weight: 400;
}
/* ── Text inputs, selects, textareas ────────────────────────────────── */
@@ -95,7 +96,7 @@ fieldset > *:not(:last-child) { margin-bottom: var(--space-xs); }
legend {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);

View File

@@ -242,6 +242,6 @@
.admin-dialog label {
display: block;
font-weight: 600;
font-weight: 400;
margin-bottom: var(--space-3xs);
}

View File

@@ -232,3 +232,68 @@
.file-browser-file .file-browser-select-btn:hover {
background: var(--bg-secondary);
}
/* ── Full-page editor (contenus-edit overtype) ─────────────────────── */
/* Make the main content area a flex column so the form fills remaining height */
.admin-body .full-editor-page {
display: flex;
flex-direction: column;
}
.admin-form--full-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.admin-form--full-editor #editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-form--full-editor #editor .n-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-form--full-editor #editor .n-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-form--full-editor #editor .n-input {
height: 100% !important;
overflow-y: auto !important;
}
.admin-form--full-editor #editor .n-preview {
height: 100% !important;
overflow-y: auto !important;
}
.full-editor-toolbar {
display: flex;
align-items: center;
gap: var(--space-s);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-s);
flex-shrink: 0;
}
.full-editor-label {
font-size: var(--step--1);
font-weight: 600;
color: var(--text-secondary);
}
.full-editor-toolbar .btn--primary {
margin-left: auto;
}

View File

@@ -68,7 +68,7 @@
> label,
.admin-form div:has(select:required) > label,
.admin-form div:has(textarea:required) > label {
font-weight: 600;
font-weight: 400;
}
/* Asterisk on required field labels */
@@ -161,6 +161,19 @@
gap: var(--space-3xs);
}
.admin-form-group--inline {
flex-direction: row;
align-items: end;
gap: var(--space-xs);
flex-wrap: wrap;
}
.admin-form-group--inline > div {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
/* ── Jury fieldset ──────────────────────────────────────────────────────── */
/* ── Website-URL inline fieldset (shown/hidden via HTMX) ────────────────── */
@@ -178,7 +191,7 @@
.admin-body fieldset fieldset.admin-jury-lecteurs > legend,
.student-body fieldset fieldset.admin-jury-lecteurs > legend {
font-size: var(--step--2);
font-weight: 600;
font-weight: 400;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-tertiary);
@@ -845,7 +858,7 @@
.retry-email-form label {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
}
.retry-email-form input[type="email"] {
@@ -1207,7 +1220,7 @@ legend {
}
.licence-options-fieldset legend {
font-weight: 600;
font-weight: 400;
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
@@ -1227,7 +1240,7 @@ legend {
.admin-form > div:not(.admin-form-footer) > label,
.admin-form > div:not(.admin-form-footer) > span.admin-row-label {
padding-top: 0;
font-weight: 500;
font-weight: 400;
}
/* Labels inside fieldsets (checkbox/radio groups) */

View File

@@ -11,16 +11,16 @@
}
/* ---- 6-column index layout ---- */
/* Column fractions: years=0.4 ap=1 or=1.2 fi=0.7 students=1 kw=1 */
/* Equal-width columns except Années (years) = narrower */
.repertoire-index {
display: grid;
grid-template-columns:
minmax(3rem, 0.4fr)
minmax(12rem, 1.4fr)
minmax(9rem, 0.8fr)
minmax(7rem, 0.8fr)
minmax(8rem, 0.7fr)
minmax(7rem, 1fr);
minmax(3rem, 0.45fr)
minmax(12rem, 1fr)
minmax(9rem, 1fr)
minmax(7rem, 1fr)
minmax(8rem, 1fr)
minmax(min-content, 1fr);
grid-template-rows: auto 1fr;
gap: var(--space-s);
justify-content: space-between;
@@ -78,14 +78,14 @@
.repertoire-col > h2 {
grid-row: 1;
font-family: var(--font-display);
font-size: var(--step-0);
font-size: var(--step--1);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
color: var(--text-primary);
font-weight: 398;
margin: 0;
padding: var(--space-xs) var(--space-2xs) var(--space-3xs);
border-bottom: 1px solid var(--border-secondary);
padding: var(--space-xs) 0 var(--space-3xs) 0;
border-bottom: 1px solid var(--text-primary);
align-self: end;
hyphens: manual;
word-break: normal;
@@ -96,7 +96,7 @@
grid-row: 2;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-2xs) var(--space-2xs) var(--space-l);
padding: var(--space-2xs) 0 var(--space-l) 0;
}
/* Strip list chrome inside repertoire columns */

View File

@@ -255,7 +255,7 @@
.tfe-access-request-form label {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
color: var(--text-primary);
}

View File

@@ -5,7 +5,14 @@
* parse errors instead of silently swallowing them (unlike the
* old autosave.js .catch(() => {}) pattern).
*/
function _handleAutosaveResponse(event) {
function handleAutosaveResponse(event) {
// Only handle responses from autosave endpoints (draft.php).
// The htmx:afterRequest event bubbles, so child elements'
// HTMX requests (e.g. licence fragment, pill-search) also
// reach this handler. We filter by URL to avoid mixing them.
const url = event.detail.requestConfig?.path || "";
if (!url.includes("draft.php")) return;
const form = event.target.closest("form");
const status = form ? form.querySelector("[data-autosave-status]") : null;
@@ -56,11 +63,17 @@ function _handleAutosaveResponse(event) {
// Show saving indicator while request is in flight
document.body.addEventListener("htmx:beforeRequest", (e) => {
const url = e.detail.requestConfig?.path || "";
if (!url.includes("draft.php")) return;
// The autosave request comes from the hidden probe div, so find
// the status indicator by searching the closest form.
const el = e.target;
if (!el) return;
const status = el.querySelector("[data-autosave-status]");
const form = el?.closest?.("form");
const status = form?.querySelector?.("[data-autosave-status]");
if (status) {
status.textContent = "Enregistrement…";
status.className = "autosave-status autosave-status--saving";
}
});

View File

@@ -277,7 +277,11 @@ function renderShareLinkForm(string $slug, array $link): void
// Filter out PACS from AP programs for student forms (spec: admin-only AP)
$apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['code'] ?? '') !== 'PACS'));
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
// Hydrate form data from session draft (autosave). Flash repopulation
// (from validation redirects) takes priority over stale draft entries.
$draftKey = 'partage_draft_' . $slug;
$draftData = $_SESSION[$draftKey] ?? [];
$formData = array_merge($draftData, $_SESSION['form_data_share_' . $slug] ?? []);
unset($_SESSION['form_data_share_' . $slug]);
// Determine allowed objet values for this link
@@ -324,7 +328,8 @@ function renderShareLinkForm(string $slug, array $link): void
// ── Shared form variables ──────────────────────────────────────────────
$mode = 'partage';
$formAction = '/partage/' . urlencode($slug) . '/submit';
$hiddenFields = '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
$hiddenFields = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($csrfToken) . '">'
. '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
$oldFn = $shareOldFn;
$withAutofocusFn = $shareWithAutofocusFn;
@@ -399,6 +404,11 @@ function renderShareLinkForm(string $slug, array $link): void
$currentContextNote = null;
$currentContactVisible = null;
// ── Autosave wiring ─────────────────────────────────────────────────┐
$autosaveUrl = '/partage/fragments/draft.php?slug=' . urlencode($slug);
$formExtraAttrs = '';
$showAutosaveStatus = true;
include APP_ROOT . '/templates/partage/form-page.php';
?>
<main id="main-content">
@@ -537,6 +547,8 @@ function handleShareLinkSubmission(string $slug): void
unset($_SESSION['share_verified_' . $slug]);
unset($_SESSION['share_active']);
unset($_SESSION['share_primed_files_' . $slug]);
// Clear autosave draft — submission succeeded
unset($_SESSION['partage_draft_' . $slug]);
// Clear FilePond temp file tracking — files have been moved to permanent storage
unset($_SESSION['filepond_tmp']);

View File

@@ -18,7 +18,8 @@ if ($thesisId <= 0) {
}
$db = Database::getInstance();
$thesis = $db->getThesis($thesisId);
// Only published theses are visible via public recap (no slug-auth here).
$thesis = $db->getThesisById($thesisId);
if (!$thesis) {
http_response_code(404);
die('TFE introuvable.');

View File

@@ -3,9 +3,7 @@
/**
* Minimal PHP session guard for the admin panel.
*
* This is a defence-in-depth layer that sits behind nginx Basic Auth.
* It protects against proxy misconfiguration, bypass, and local-dev
* scenarios where the reverse proxy may be absent.
* Password-only authentication via an HTML login form.
*
* The admin password hash is stored in the site_settings table
* (key = 'admin_password_hash').
@@ -17,6 +15,10 @@ class AdminAuth
private const SESSION_KEY = 'admin_authenticated';
private const LOGIN_URL = '/admin/login.php';
// Throttle: max 5 attempts before mandatory delay, cooldown 15 min.
private const MAX_ATTEMPTS = 5;
private const COOLDOWN_MINUTES = 15;
/**
* Start the PHP session with hardened cookie parameters.
* Idempotent — safe to call even if session is already active.
@@ -61,10 +63,7 @@ class AdminAuth
* Authentication order:
* 1. No password hash configured → dev mode, pass through.
* 2. Session already authenticated → pass through.
* 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW']
* → validate it with password_verify; on success create session
* (seamless: user only sees the browser Basic Auth dialog).
* 4. Neither → redirect to the PHP login form.
* 3. Neither → redirect to the PHP login form.
*/
public static function requireLogin(): void
{
@@ -76,11 +75,6 @@ class AdminAuth
if (!empty($_SESSION[self::SESSION_KEY])) {
return; // Already authenticated via session.
}
// Try to auto-authenticate from the nginx Basic Auth credentials.
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
$_SESSION[self::SESSION_KEY] = true;
return;
}
header('Location: ' . self::LOGIN_URL);
exit;
}
@@ -89,15 +83,54 @@ class AdminAuth
* Validate a plaintext password against the stored hash.
* On success: regenerates the session ID and marks the session authenticated.
*
* Throttling: after MAX_ATTEMPTS consecutive failures, a mandatory delay is
* enforced (incremental: 1s, 2s, 4s, … up to 60s). Returns the same `false`
* result as a wrong password so the attacker cannot distinguish the reason.
*
* @return bool true on success, false on wrong password / no hash stored.
*/
public static function login(string $password): bool
{
$storedHash = self::getStoredHash();
if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
if ($storedHash === null) {
return false;
}
self::startSession();
$alreadyAuthed = !empty($_SESSION[self::SESSION_KEY]);
// ── Throttle: only on unauthenticated login attempts ────────────────
if (!$alreadyAuthed) {
$attempts = (int) ($_SESSION['auth_attempts'] ?? 0);
$firstAt = (int) ($_SESSION['auth_first_attempt'] ?? 0);
$now = time();
// Cooldown window — reset after COOLDOWN_MINUTES
if ($attempts > 0 && ($now - $firstAt) > self::COOLDOWN_MINUTES * 60) {
$attempts = 0;
$firstAt = 0;
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
}
if ($attempts >= self::MAX_ATTEMPTS) {
$extra = $attempts - self::MAX_ATTEMPTS;
$delay = min(1 << min($extra, 6), 60); // 1s → 2s → 4s … → 60s cap
sleep($delay);
}
}
if (!self::verifyHash($password, $storedHash)) {
if (!$alreadyAuthed) {
if ($attempts === 0) {
$_SESSION['auth_first_attempt'] = $now;
}
$_SESSION['auth_attempts'] = $attempts + 1;
}
return false;
}
// ── Success: clear throttling, create/refresh session ──────────────
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = true;
$_SESSION['admin_login_at'] = time();
@@ -145,12 +178,6 @@ class AdminAuth
if (!empty($_SESSION[self::SESSION_KEY])) {
return true;
}
// Also accept nginx Basic Auth credentials directly (e.g. HTMX fragment
// requests that arrive before a PHP session has been established).
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
$_SESSION[self::SESSION_KEY] = true;
return true;
}
return false;
}

View File

@@ -3,6 +3,7 @@
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
require_once APP_ROOT . '/src/MarkdownHelper.php';
use League\CommonMark\CommonMarkConverter;
@@ -29,9 +30,12 @@ class CharteController
$converter = new CommonMarkConverter(['html_input' => 'strip']);
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
$tocItems = MarkdownHelper::extractToc($content);
return [
'content' => $content,
'html' => $html,
'tocItems' => $tocItems,
'pageTitle' => $pageTitle . ' XAMXAM',
'metaDescription' => "Charte d'utilisation de XAMXAM, le répertoire des TFE de l'erg.",
'currentNav' => 'charte',
@@ -39,4 +43,5 @@ class CharteController
'bodyClass' => 'apropos-body',
];
}
}

View File

@@ -3,6 +3,7 @@
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
require_once APP_ROOT . '/src/MarkdownHelper.php';
use League\CommonMark\CommonMarkConverter;
@@ -29,9 +30,12 @@ class LicenceController
$converter = new CommonMarkConverter(['html_input' => 'strip']);
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
$tocItems = MarkdownHelper::extractToc($content);
return [
'content' => $content,
'html' => $html,
'tocItems' => $tocItems,
'pageTitle' => $pageTitle . ' XAMXAM',
'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur XAMXAM, le répertoire des TFE de l'erg.",
'currentNav' => 'licence',
@@ -39,4 +43,5 @@ class LicenceController
'bodyClass' => 'apropos-body',
];
}
}

View File

@@ -162,6 +162,8 @@ class ThesisCreateController
'exemplaire_baiu' => $data['exemplaireBaiu'],
'exemplaire_erg' => $data['exemplaireErg'],
'cc2r' => $data['cc2r'],
'duration_value' => $data['durationValue'],
'duration_unit' => $data['durationUnit'],
]);
$identifier = $this->db->getThesisIdentifier($thesisId);
@@ -548,6 +550,22 @@ class ThesisCreateController
$exemplaireErg = !empty($post['exemplaire_erg']);
$cc2r = !empty($post['cc2r']);
// Duration: numeric value + unit (optional, admin-validated)
$validDurationUnits = ['pages', 'minutes', 'sec', 'heures', 'mo'];
$durationValue = $post['duration_value'] ?? null;
$durationUnit = $post['duration_unit'] ?? 'pages';
if ($durationValue !== null && $durationValue !== '') {
$durationValue = filter_var($durationValue, FILTER_VALIDATE_FLOAT);
if ($durationValue === false || $durationValue <= 0) {
$durationValue = null; // ignore invalid
}
} else {
$durationValue = null;
}
if (!in_array($durationUnit, $validDurationUnits, true)) {
$durationUnit = 'pages';
}
// Annexes are optional — no validation required
$hasAnnexes = !empty($post['has_annexes']);
@@ -577,7 +595,9 @@ class ThesisCreateController
'juryPoints',
'exemplaireBaiu',
'exemplaireErg',
'cc2r'
'cc2r',
'durationValue',
'durationUnit'
);
}

View File

@@ -109,6 +109,8 @@ class ThesisEditController
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
$currentContextNote = $rawRow['context_note'] ?? '';
$currentContactVisible = $rawRow['contact_visible'] ?? '';
$currentDurationValue = $rawRow['duration_value'] ?? null;
$currentDurationUnit = $rawRow['duration_unit'] ?? 'pages';
// Author contact info (from view)
$contactInterne = $thesis['contact_interne'] ?? '';
@@ -132,6 +134,8 @@ class ThesisEditController
'currentAccessTypeId' => $currentAccessTypeId,
'currentContextNote' => $currentContextNote,
'currentContactVisible' => $currentContactVisible,
'currentDurationValue' => $currentDurationValue,
'currentDurationUnit' => $currentDurationUnit,
'contactInterne' => $contactInterne,
'contactPublic' => $contactPublic,
'currentRaw' => $rawRow,
@@ -219,6 +223,8 @@ class ThesisEditController
'exemplaire_erg' => !empty($post['exemplaire_erg']),
'cc2r' => !empty($post['cc2r']),
'license_custom' => trim($post['license_custom'] ?? ''),
'duration_value' => isset($post['duration_value']) && $post['duration_value'] !== '' ? (float)$post['duration_value'] : null,
'duration_unit' => !empty($post['duration_unit']) ? $post['duration_unit'] : 'pages',
];
// Regenerate identifier if year changed or if identifier prefix doesn't match year
$oldThesis = $this->db->getThesis($thesisId);

View File

@@ -41,18 +41,7 @@ class Database
*/
private function runMigrations(): void
{
// Add 'name' column to share_links if missing
try {
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT');
} catch (\PDOException $e) {
// Column already exists — ignore
}
// Add 'locked_year' column to share_links if missing
try {
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER');
} catch (\PDOException $e) {
// Column already exists — ignore
}
(new DatabaseMigrations($this->pdo))->run();
}
/**
@@ -2037,7 +2026,7 @@ class Database
public function getThesisRawFields(int $thesisId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, is_published FROM theses WHERE id = ? LIMIT 1'
'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, duration_value, duration_unit, is_published FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$row = $stmt->fetch();
@@ -2181,6 +2170,8 @@ class Database
exemplaire_baiu = ?,
exemplaire_erg = ?,
cc2r = ?,
duration_value = ?,
duration_unit = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
@@ -2212,6 +2203,8 @@ class Database
!empty($data['exemplaire_baiu']) ? 1 : 0,
!empty($data['exemplaire_erg']) ? 1 : 0,
!empty($data['cc2r']) ? 1 : 0,
isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null,
!empty($data['duration_unit']) ? $data['duration_unit'] : 'pages',
$thesisId,
]);
$stmt->execute($params);
@@ -2262,8 +2255,9 @@ class Database
remarks, jury_points,
exemplaire_baiu, exemplaire_erg,
cc2r,
duration_value, duration_unit,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
');
$validObjet = ['tfe', 'thèse', 'frart'];
@@ -2296,6 +2290,8 @@ class Database
!empty($data['exemplaire_baiu']) ? 1 : 0,
!empty($data['exemplaire_erg']) ? 1 : 0,
!empty($data['cc2r']) ? 1 : 0,
isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null,
!empty($data['duration_unit']) ? $data['duration_unit'] : 'pages',
]);
$newId = (int)$this->pdo->lastInsertId();

View File

@@ -0,0 +1,92 @@
<?php
/**
* One-off database migrations.
*
* Applied once on every Database constructor call, via Database::runMigrations().
* Each migration is idempotent — safe to run multiple times.
*/
class DatabaseMigrations
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Run all pending migrations.
*/
public function run(): void
{
$this->migrateRenameFinalityTypes();
$this->migrateShareLinksNameColumn();
$this->migrateShareLinksLockedYearColumn();
}
/**
* 2026-06-11 — Rename finality types + relink theses.
*
* Spécialisé → Spécialisée
* Approfondi → Approfondie
* Enseignement → Didactique
*/
private function migrateRenameFinalityTypes(): void
{
try {
$renames = [
'Spécialisé' => 'Spécialisée',
'Approfondi' => 'Approfondie',
'Enseignement' => 'Didactique',
];
foreach ($renames as $old => $new) {
// Skip if only canonical row already exists
$oldCount = $this->pdo->query("SELECT COUNT(*) FROM finality_types WHERE name = '$old'")->fetchColumn();
if ($oldCount == 0) {
continue;
}
// Relink theses from old row to canonical row (create if needed)
$canonical = $this->pdo->query("SELECT id FROM finality_types WHERE name = '$new'")->fetchColumn();
if (!$canonical) {
$this->pdo->exec("INSERT INTO finality_types (name) VALUES ('$new')");
$canonical = $this->pdo->lastInsertId();
}
// Relink
$this->pdo->exec("
UPDATE theses SET finality_id = $canonical
WHERE finality_id IN (SELECT id FROM finality_types WHERE name = '$old')
");
// Delete old row
$this->pdo->exec("DELETE FROM finality_types WHERE name = '$old'");
}
} catch (\PDOException $e) {
// Table may not exist yet on fresh install — ignore
}
}
/**
* Add 'name' column to share_links if missing.
*/
private function migrateShareLinksNameColumn(): void
{
try {
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT');
} catch (\PDOException $e) {
// Column already exists — ignore
}
}
/**
* Add 'locked_year' column to share_links if missing.
*/
private function migrateShareLinksLockedYearColumn(): void
{
try {
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER');
} catch (\PDOException $e) {
// Column already exists — ignore
}
}
}

View File

@@ -7,7 +7,7 @@
* called from both the admin panel and the student partage form.
*
* Auth is checked by the caller before invoking these methods:
* - Admin endpoints: nginx auth_basic + AdminAuth::requireLogin()
* - Admin endpoints: AdminAuth::requireLogin()
* - Partagé endpoints: session_start() + verify share_active + CSRF
*
* All paths in this file assume the session is already started and CSRF is

View File

@@ -32,6 +32,7 @@ class FormBootstrap
'/assets/js/app/beforeunload-guard.js',
'/assets/js/app/pill-search.js',
'/assets/js/app/jury-autocomplete.js',
'/assets/js/app/autosave-handler.js',
],
];
}
@@ -117,13 +118,47 @@ class FormBootstrap
$generalitiesHtml = $helpFn('fieldset_generalites');
$defaultAccessTypeId = $options['defaultAccessTypeId'] ?? 2;
// ── Autosave draft wiring (add / edit only) ─────────────────────┐
$autosaveUrl = '/admin/actions/draft.php';
$formExtraAttrs = '';
$showAutosaveStatus = false;
$extraHidden = '';
if ($mode === 'add') {
// Reuse draft token from session so drafts survive page reloads
if (empty($_SESSION['admin_draft_add_token'])) {
$_SESSION['admin_draft_add_token'] = bin2hex(random_bytes(8));
}
$draftToken = $_SESSION['admin_draft_add_token'];
$draftKey = 'admin_draft_' . $draftToken;
$extraHidden = '<input type="hidden" name="draft_token" value="' . $draftToken . '">';
// Hydrate from any previous session (survives accidental navigations)
$draft = $_SESSION[$draftKey] ?? [];
$formData = array_merge($draft, $formData);
$showAutosaveStatus = true;
} elseif ($mode === 'edit') {
$thesisId = (int)($options['thesisId'] ?? 0);
if ($thesisId > 0) {
$draftKey = 'admin_draft_edit_' . $thesisId;
$extraHidden = '<input type="hidden" name="thesis_id" value="' . $thesisId . '">';
$draft = $_SESSION[$draftKey] ?? [];
$formData = array_merge($draft, $formData);
$showAutosaveStatus = true;
}
}
if ($showAutosaveStatus) {
$formExtraAttrs = 'hx-post="' . htmlspecialchars($autosaveUrl) . '"';
}
return array_merge([
// Base
'mode' => $mode,
'formAction' => $formAction,
'hiddenFields' => $hiddenFields,
'hiddenFields' => $hiddenFields . $extraHidden,
'errorFieldName' => $autofocusField,
'synopsisExtra' => $options['synopsisExtra'] ?? '',
'formExtraAttrs' => $formExtraAttrs,
'showAutosaveStatus' => $showAutosaveStatus,
'autosaveUrl' => $autosaveUrl,
// Helpers
'helpFn' => $helpFn,
@@ -174,6 +209,8 @@ class FormBootstrap
'contactPublic' => false,
'currentContextNote' => null,
'currentContactVisible' => null,
'currentDurationValue' => null,
'currentDurationUnit' => 'pages',
// Files (edit mode)
'currentCover' => null,

View File

@@ -0,0 +1,34 @@
<?php
/**
* Shared markdown utilities.
*/
class MarkdownHelper
{
/**
* Extract h1h3 headings from raw markdown content as TOC items.
*
* Each heading gets an anchor id matching CommonMark's default slugification
* (lowercase, spaces → hyphens, punctuation stripped).
*
* @return array<int, array{label: string, href: string}>
*/
public static function extractToc(string $content): array
{
$items = [];
$lines = explode("\n", $content);
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $m)) {
$label = trim($m[1]);
// Replicate CommonMark's default heading ID generation:
// lowercase, strip non-word chars (except hyphens/spaces), spaces→hyphens
$id = strtolower($label);
$id = preg_replace('/[^\w\s-]/u', '', $id);
$id = preg_replace('/\s+/', '-', $id);
$id = trim($id, '-');
$items[] = ['label' => $label, 'href' => '#' . $id];
}
}
return $items;
}
}

View File

@@ -228,6 +228,16 @@ class ShareLink
)->execute([$id]);
}
/**
* Permanently delete an archived share link.
*/
public function delete(int $id): void
{
$this->db->getConnection()->prepare(
'DELETE FROM share_links WHERE id = ? AND is_archived = 1'
)->execute([$id]);
}
/**
* Increment the usage count for a share link.
*/

View File

@@ -89,6 +89,8 @@ CREATE TABLE IF NOT EXISTS theses (
context_note TEXT,
contact_visible TEXT DEFAULT NULL,
remarks TEXT,
duration_value REAL,
duration_unit TEXT DEFAULT 'pages',
access_type_id INTEGER,
license_id INTEGER,
jury_points DECIMAL(4,2),
@@ -410,6 +412,8 @@ SELECT
t.synopsis,
t.context_note,
t.contact_visible,
t.duration_value,
t.duration_unit,
at.name as access_type,
lt.name as license_type,
t.license_id,
@@ -526,9 +530,9 @@ INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Atelier Pratiques Situé
INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Lieux, Interdisciplinarités, Écologie, Nécessité, Systèmes', 'LIENS');
INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Pratique de l''art - outils critiques, arts et contexte simultanés', 'PACS');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondi');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisé');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondie');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisée');
INSERT OR IGNORE INTO languages (name) VALUES ('français');
INSERT OR IGNORE INTO languages (name) VALUES ('anglais');
@@ -563,6 +567,7 @@ INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_frart_enabled',
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_these_enabled', '0');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('peertube_upload_enabled', '1');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('restricted_files_enabled', '1');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('admin_password_hash', '');
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('about', 'À propos', 'Contenu à venir', 1);
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('charte', 'Charte', 'Contenu à venir', 1);

View File

@@ -1318,6 +1318,8 @@ CREATE TABLE IF NOT EXISTS theses (
remarks TEXT, -- Internal remarks
-- Duration/size
duration_value REAL,
duration_unit TEXT DEFAULT 'pages',
access_type_id INTEGER,
license_id INTEGER,
@@ -4347,6 +4349,8 @@ SELECT
ft.name as finality_type,
t.synopsis,
t.context_note,
t.duration_value,
t.duration_unit,
at.name as access_type,
lt.name as license_type,
t.license_id,

View File

@@ -152,6 +152,7 @@
<th scope="col">Utilisations</th>
<th scope="col">Expiration</th>
<th scope="col">Créé le</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@@ -175,6 +176,12 @@
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
<td><?= $expires ?></td>
<td><?= $created ?></td>
<td class="admin-actions-col">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="openDeleteArchivedLinkDialog(<?= $link['id'] ?>)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -641,6 +648,10 @@ function _executeArchiveLink() {
const form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId);
if (form) form.submit();
}
function openDeleteArchivedLinkDialog(id) {
document.getElementById('delete-archived-link-id').value = id;
document.getElementById('delete-archived-link-dialog').showModal();
}
</script>
<!-- Archive link confirm -->
@@ -658,3 +669,24 @@ function _executeArchiveLink() {
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<!-- ═══════════════════════ DELETE ARCHIVED LINK DIALOG ═══════════════════════ -->
<dialog id="delete-archived-link-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-archived-link-title">
<div class="admin-dialog__header">
<h2 id="delete-archived-link-title">Supprimer le lien</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Supprimer définitivement ce lien archivé ? Cette action est irréversible.</p>
</div>
<div class="admin-dialog__footer">
<form method="post" action="actions/acces-etudiante.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" id="delete-archived-link-id" value="">
<button type="submit" class="btn btn--danger">Supprimer</button>
</form>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>

View File

@@ -1,30 +1,28 @@
<main id="main-content">
<main id="main-content" class="full-editor-page">
<h1><a href="/admin/contenus.php" class="admin-back-btn" title="Retour"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H168A8,8,0,0,1,176,128Z"></path></svg></a> Éditer : <?= htmlspecialchars($editTitle) ?></h1>
<?php if ($editType === 'about_page'): ?>
<!-- ── Markdown content ──────────────────────────────────────────────── -->
<h2>Contenu de la page</h2>
<form action="/admin/actions/page.php" method="post" class="admin-form"
hx-post="/admin/actions/page.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="about">
<label for="editor">Contenu (Markdown) :</label>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<div class="full-editor-toolbar">
<span class="full-editor-label">Contenu (Markdown) :</span>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form>
<!-- ── Contacts ──────────────────────────────────────────────────────── -->
@@ -137,50 +135,46 @@
</script>
<?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?>
<form action="/admin/actions/page.php" method="post" class="admin-form"
hx-post="/admin/actions/page.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
<label for="editor">Contenu (Markdown) :</label>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<div class="full-editor-toolbar">
<span class="full-editor-label">Contenu (Markdown) :</span>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form>
<?php elseif ($editType === 'form_help'): ?>
<p class="param-note">Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.</p>
<form action="/admin/actions/form-help.php" method="post" class="admin-form"
hx-post="/admin/actions/form-help.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<form action="/admin/actions/form-help.php" method="post" class="admin-form admin-form--full-editor">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>">
<label for="editor">Contenu (Markdown) :</label>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<div class="full-editor-toolbar">
<span class="full-editor-label">Contenu (Markdown) :</span>
<button type="button" class="btn btn--sm"
hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-target="#md-cheatsheet-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form>
<?php else: ?>

View File

@@ -69,14 +69,6 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
<option value="<?= $y ?>" <?= $yearFilter == $y ? 'selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="orientation">
<option value="">Orientation</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="ap">
<option value="">AP</option>
<?php foreach ($apPrograms as $ap): ?>
@@ -85,6 +77,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
</option>
<?php endforeach; ?>
</select>
<select name="orientation">
<option value="">Orientation</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">&#x2715; Réinitialiser</a>
<?php endif; ?>

View File

@@ -360,14 +360,14 @@
<!-- Danger zone: remove credentials -->
<?php if ($hasPassword): ?>
<fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend>
<legend>Supprimer la configuration du mot de passe</legend>
<p>
Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
ne sera plus protégé (mode développement).
</p>
<?php /* TODO: replace this browser confirm() with a proper <dialog> modal like the other confirmations */ ?>
<form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">
onsubmit="return confirm('Supprimer le mot de passe ? L\'accès admin ne sera plus protégé.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php">

View File

@@ -2,6 +2,7 @@
// header.php — unified site header for public and admin sections.
// Reads: $isAdmin (bool), $currentNav (string, public only)
$_isAdmin = !empty($isAdmin);
$_isLogin = !empty($isLogin);
$_navCurrent = $currentNav ?? '';
$_currentPage = basename($_SERVER['PHP_SELF']);
$_thesisId = $_GET['id'] ?? null;
@@ -9,7 +10,7 @@ $_thesisId = $_GET['id'] ?? null;
<header>
<a href="#main-content" class="skip-link">Aller au contenu principal</a>
<?php if ($_isAdmin): ?>
<?php if ($_isAdmin && !$_isLogin): ?>
<nav aria-label="Navigation admin">
<ul class="nav-left-links">
@@ -87,7 +88,7 @@ $_thesisId = $_GET['id'] ?? null;
</header>
<?php if ($_isAdmin): ?>
<?php if ($_isAdmin && !$_isLogin): ?>
<div class="admin-mobile-block">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><rect width="256" height="256" fill="none"/><rect x="24" y="56" width="208" height="144" rx="16" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><line x1="24" y1="168" x2="104" y2="88" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><line x1="152" y1="168" x2="232" y2="88" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Section administrateur</h2>
@@ -95,7 +96,7 @@ $_thesisId = $_GET['id'] ?? null;
</div>
<?php endif; ?>
<?php if (!$_isAdmin): ?>
<?php if (!$_isAdmin && !$_isLogin): ?>
<?php
// Search bar — public section only (rendered below header for equal height)
$searchBarValue = $searchBarValue ?? $_GET['query'] ?? '';

View File

@@ -58,6 +58,7 @@ $filepondBase = $filepondBase ?? null;
<?php if ($includeFilePond): ?>
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/autosave-handler.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
<?php endif; ?>
<?php foreach ($extraJs as $js): ?>

View File

@@ -64,4 +64,6 @@ $ariaDescribedBy = ($errorFieldName === $name) ? ' aria-describedby="flash-error
</fieldset>
</div>
<?php
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude, $ariaInvalid, $ariaDescribedBy, $errorFieldName);
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude, $ariaInvalid, $ariaDescribedBy);
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
// consumed by downstream partials (e.g. fichiers-fragment.php).

View File

@@ -89,7 +89,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
data-queue-type="cover"
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>'
aria-describedby="couverture-hint">
<small id="couverture-hint">JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.</small>
<small id="couverture-hint">JPG, PNG ou WEBP. Format 4:3 recommandé (ex. 1200 × 900 px). Max 20 MB.</small>
</div>
<?php if ($editMode): ?>
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger"

View File

@@ -20,7 +20,7 @@ $adminMode = $adminMode ?? false;
$name = 'couverture';
$label = 'Image de couverture (optionnel) :';
$accept = 'image/jpeg,image/png,image/webp';
$hint = 'JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.';
$hint = 'JPG, PNG ou WEBP. Format 4:3 recommandé (ex. 1200 × 900 px). Max 20 MB.';
include APP_ROOT . '/templates/partials/form/file-field.php';
?>

View File

@@ -102,6 +102,10 @@ $existingWebsiteUrl = $existingWebsiteUrl ?? '';
$existingWebsiteLabel = $existingWebsiteLabel ?? '';
$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
// Duration (value + unit)
$durationValue = $durationValue ?? null;
$durationUnit = $durationUnit ?? 'pages';
// WCAG 3.3.1: which field has a validation error (set by caller from App::consumeAutofocus())
$errorFieldName = $errorFieldName ?? null;
?>
@@ -413,8 +417,38 @@ if ($filesMode === 'add'): ?>
<?php endif; ?>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════
(Durée/Nombre de pages supprimés — redondants avec les fichiers attachés) -->
<!-- ═══════════════════ Durée ═══════════════════ -->
<fieldset>
<legend>Durée</legend>
<div class="admin-form-group admin-form-group--inline">
<div>
<label for="duration_unit">Unité :</label>
<select id="duration_unit" name="duration_unit">
<?php
$_currentUnit = $durationUnit ?? ($formData['duration_unit'] ?? 'pages');
$_units = [
'pages' => 'pages',
'minutes' => 'minutes',
'sec' => 'secondes',
'heures' => 'heures',
'mo' => 'Mo',
];
foreach ($_units as $_val => $_label): ?>
<option value="<?= $_val ?>" <?= $_currentUnit === $_val ? 'selected' : '' ?>><?= htmlspecialchars($_label) ?></option>
<?php endforeach; unset($_units, $_currentUnit, $_val, $_label); ?>
</select>
</div>
<div>
<label for="duration_value">Valeur :</label>
<input type="number" id="duration_value" name="duration_value"
value="<?= htmlspecialchars((string)($durationValue ?? ($formData['duration_value'] ?? ''))) ?>"
step="0.1" min="0" placeholder="0"
style="width: 8ch;">
</div>
</div>
<small>Optionnel. Exemples : 88 pages, 32 minutes, 1.5 heures, 120 Mo.</small>
</div>
</fieldset>
<!-- ═══════════════════ Degrés d'ouverture et licences ═══════════════════ -->
<?php
@@ -552,6 +586,16 @@ if ($filesMode === 'add'): ?>
<?php endif; ?>
<?php if ($showAutosaveStatus): ?>
<!-- Hidden autosave element: polls the form every 3s and POSTs to draft.php.
Uses hx-include to serialize the entire form. hx-swap="none" so response
doesn't alter the DOM. -->
<div hx-post="<?= htmlspecialchars($autosaveUrl ?? '') ?>"
hx-trigger="every 3s"
hx-include="closest form"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)"
data-autosave-probe
aria-hidden="true" style="display:none"></div>
<div class="autosave-status" data-autosave-status></div>
<?php endif; ?>

View File

@@ -73,4 +73,6 @@ foreach ($attrs as $k => $v) {
<?php endif; ?>
</div>
<?php
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid);
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
// consumed by downstream partials (e.g. fichiers-fragment.php).

View File

@@ -74,4 +74,6 @@ $ariaInvalid = ($errorFieldName === $name) ? ' aria-invalid="true" aria-errormes
</div>
<?php
// Reset consumed variables so includes in a loop don't bleed state.
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid);
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
// consumed by the parent partial (e.g. fieldset-tfe-info.php for the synopsis textarea).

View File

@@ -95,7 +95,7 @@ $filterColumns = [
['dataKey' => 'years', 'dim' => 'years', 'heading' => 'Années'],
['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'],
['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'],
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du Master'],
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du&nbsp;Master'],
['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'],
];
@@ -118,7 +118,7 @@ foreach ($renderOrder as $colKey):
if ($colKey === 'students'): ?>
<!-- ÉTUDIANTES -->
<section class="repertoire-col" data-col="students">
<h2>Étudiantes</h2>
<h2>Étudiant·es</h2>
<ul>
<?php if (empty($studentWorks)): ?>
<li class="rep-empty">—</li>
@@ -147,7 +147,7 @@ foreach ($renderOrder as $colKey):
<?php else:
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; ?>
<section class="repertoire-col" data-col="<?= $col['dim'] ?>">
<h2><?= htmlspecialchars($col['heading']) ?></h2>
<h2><?= $col['heading'] ?></h2>
<ul>
<?php foreach ($repData[$col['dataKey']] as $item):
repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $colHasMatches[$col['dim']], $hx);

View File

@@ -1,9 +1,30 @@
<main class="apropos-main" id="main-content">
<div class="prose apropos-single">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<div class="apropos-layout">
<!-- LEFT: sticky table of contents -->
<?php if (!empty($tocItems)): ?>
<nav class="apropos-toc" aria-label="Sections de la page">
<p class="apropos-toc-label">Parties</p>
<ul>
<?php foreach ($tocItems as $item): ?>
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
<!-- MIDDLE: main prose -->
<div class="apropos-content">
<section class="apropos-section">
<div class="prose">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</section>
</div>
</div>
</main>

View File

@@ -1,9 +1,30 @@
<main class="apropos-main" id="main-content">
<div class="prose apropos-single">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<div class="apropos-layout">
<!-- LEFT: sticky table of contents -->
<?php if (!empty($tocItems)): ?>
<nav class="apropos-toc" aria-label="Sections de la page">
<p class="apropos-toc-label">Parties</p>
<ul>
<?php foreach ($tocItems as $item): ?>
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
<!-- MIDDLE: main prose -->
<div class="apropos-content">
<section class="apropos-section">
<div class="prose">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</section>
</div>
</div>
</main>

View File

@@ -17,18 +17,6 @@
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option>
@@ -41,6 +29,18 @@
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-finality">Finalité
<select class="search-filter-select" name="finality" id="filter-finality">
<option value="">Toutes</option>

View File

@@ -46,6 +46,27 @@
</p>
<?php endif; ?>
<?php if (!empty($data["duration_value"]) && !empty($data["duration_unit"])): ?>
<?php
$_dVal = (float)$data["duration_value"];
$_dUnit = $data["duration_unit"];
$_unitLabels = [
'pages' => 'pages',
'minutes' => 'minutes',
'sec' => 'secondes',
'heures' => 'heures',
'mo' => 'Mo',
];
$_label = $_unitLabels[$_dUnit] ?? $_dUnit;
// if float, show 0.1 or .0 as needed
$_display = ($_dVal == (int)$_dVal) ? (int)$_dVal : $_dVal;
?>
<p class="tfe-meta-item">
<span class="tfe-meta-label">Durée :</span>
<?= $_display ?> <?= htmlspecialchars($_label) ?>
</p>
<?php unset($_unitLabels, $_dVal, $_dUnit, $_label, $_display); endif; ?>
<?php if (!empty($data["languages"])): ?>
<p class="tfe-meta-item">
<span class="tfe-meta-label">Langue :</span>