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

File diff suppressed because one or more lines are too long

View File

@@ -65,17 +65,9 @@ just deploy
just deploy-nginx just deploy-nginx
``` ```
### Admin users (htpasswd)
```bash
just manage-admin-users
# Then on server:
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
```
## Security notes ## Security notes
- Admin panel protected by nginx `auth_basic` + PHP session (`AdminAuth`) - Admin panel protected by PHP session (`AdminAuth`) — password-only, no username
- Uploads stored outside webroot, served via controlled `media.php` - Uploads stored outside webroot, served via controlled `media.php`
- Rate limiting on public search (`src/RateLimit.php`) - Rate limiting on public search (`src/RateLimit.php`)
- See `nginx/docs/SECURITY_HEADERS.md` for security headers reference - See `nginx/docs/SECURITY_HEADERS.md` for security headers reference

14
TODO.md
View File

@@ -1,15 +1,26 @@
# TODO # TODO
> Last updated: 2026-06-11 > Last updated: 2026-06-11
> Context: Form Accessibility & Resilience improvements for XAMXAM thesis submission platform > Context: Removed overtype autosave (403 CSRF bug), replaced with explicit Save button above full-page editor
## Pending ## Pending
- [ ] #apropos-toc-confirm Visually confirm charte + licence TOC layout renders correctly in browser
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows - [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES` - [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221
## Completed ## Completed
- [x] #csp-media-iframe-fix Fix CSP `frame-ancestors 'none'` blocking PDF iframes — replaced `try_files` redirect with direct `fastcgi_pass` in `location = /media` so `add_header` CSP override survives internal nginx redirect `(nginx/xamxam.conf)`
- [x] #duration-migration Add migration to reintroduce `duration_value` and `duration_unit` columns + update views `(migrations/applied/040_duration_fields.sql)`
- [x] #duration-database Update `createThesis`, `updateThesis`, `getThesisRawFields` in Database `(Database.php)`
- [x] #duration-controllers Handle duration in `ThesisCreateController` and `ThesisEditController` `(ThesisCreateController.php, ThesisEditController.php)`
- [x] #duration-form Add duration fieldset (value + unit dropdown) to form template `(templates/partials/form/form.php)`
- [x] #duration-display Show duration on public TFE detail page `(templates/public/tfe.php)`
- [x] #duration-view Include duration in v_theses_full and v_theses_public `(migrations/applied/040_duration_fields.sql, schema.sql, schema.sql.new)`
- [x] #duration-bootstrapWire Wire duration variables through FormBootstrap adminFormVariables `(FormBootstrap.php)`
- [x] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`Database.php`, `justfile`, `deploy/xamxam-cleanup.cron`, `scripts/cleanup-drafts.php`) ✓ - [x] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`Database.php`, `justfile`, `deploy/xamxam-cleanup.cron`, `scripts/cleanup-drafts.php`) ✓
- [x] #form-setup-helper Add `FormBootstrap` helper class to reduce bootstrap duplication across add/edit/partage `(admin/add.php)` `(admin/edit.php)` - [x] #form-setup-helper Add `FormBootstrap` helper class to reduce bootstrap duplication across add/edit/partage `(admin/add.php)` `(admin/edit.php)`
- [x] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)` - [x] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)`
@@ -20,6 +31,7 @@
- [x] #aria-errormessage WCAG AA: field-level `aria-errormessage`, `aria-invalid`, `aria-describedby` on all form fields ✓ - [x] #aria-errormessage WCAG AA: field-level `aria-errormessage`, `aria-invalid`, `aria-describedby` on all form fields ✓
- [x] #nojs-upload-fix No-JS file uploads: `filepond_mode` default to `0 disabled`, server-side `$_FILES` fallback ✓ - [x] #nojs-upload-fix No-JS file uploads: `filepond_mode` default to `0 disabled`, server-side `$_FILES` fallback ✓
- [x] #autosave-partage Autosave text fields on partage form: session draft endpoint (`fragments/draft.php`), HTMX autosave on change/input, page-load hydration, "Brouillon enregistré" indicator, draft cleared on submit ✓ - [x] #autosave-partage Autosave text fields on partage form: session draft endpoint (`fragments/draft.php`), HTMX autosave on change/input, page-load hydration, "Brouillon enregistré" indicator, draft cleared on submit ✓
- [x] #autosave-partage-wire Wire `formExtraAttrs`, `showAutosaveStatus`, draft hydration, `autosave-handler.js`, draft cleanup into partage form (`partage/index.php`, `partage/form-page.php`) ✓
- [x] #mobile-responsive Mobile-responsive form layout: `@media (max-width: 600px)` breakpoint, 44×44px touch targets ✓ - [x] #mobile-responsive Mobile-responsive form layout: `@media (max-width: 600px)` breakpoint, 44×44px touch targets ✓
- [x] #aria-fieldset-fix Remove invalid `required` attribute from `<fieldset>`, keep `aria-required="true"`, add `role="group"` - [x] #aria-fieldset-fix Remove invalid `required` attribute from `<fieldset>`, keep `aria-required="true"`, add `role="group"`
- [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css` - [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css`

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/ShareLink.php';
require_once __DIR__ . '/../../../src/AdminLogger.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(); App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' if ($_SERVER['REQUEST_METHOD'] !== 'POST'
@@ -90,6 +88,15 @@ switch ($action) {
} }
break; 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': case 'update':
if ($id > 0) { if ($id > 0) {
$name = isset($_POST['name']) ? trim($_POST['name']) : null; $name = isset($_POST['name']) ? trim($_POST['name']) : null;

View File

@@ -6,7 +6,6 @@
*/ */
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) 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/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.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(); AdminAuth::requireLogin();
// ── CSRF ────────────────────────────────────────────────────────────────────── // ── CSRF ──────────────────────────────────────────────────────────────────────

View File

@@ -8,7 +8,6 @@
*/ */
require_once __DIR__ . "/../../../bootstrap.php"; require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) $isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

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

View File

@@ -1,8 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php'; 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__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.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) // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin(); AdminAuth::requireLogin();
@@ -37,6 +35,10 @@ try {
// Regenerate CSRF token after successful save // Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_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'] ?? ''); AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
App::flash('success', "TFE mis à jour avec succès!"); 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 // CSRF via header
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $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']) if (!isset($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) { || !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
relinkError(403, 'Token CSRF invalide.'); relinkError(403, 'Token CSRF invalide.');

View File

@@ -6,7 +6,6 @@
*/ */
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) $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__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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). // Only suppress display_errors in production (cli-server = dev mode).
if (php_sapi_name() !== 'cli-server') { if (php_sapi_name() !== 'cli-server') {
ini_set('display_errors', 0); 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.'); 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/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/AppLogger.php'; require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/AdminLogger.php'; require_once APP_ROOT . '/src/AdminLogger.php';
@@ -45,6 +41,10 @@ try {
$logger->logSubmission('admin', $thesisId, $identifier, $authorName); $logger->logSubmission('admin', $thesisId, $identifier, $authorName);
$adminLogger->logAdd($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']); unset($_SESSION['csrf_token']);
$redirect = '../recapitulatif.php?id=' . $thesisId; $redirect = '../recapitulatif.php?id=' . $thesisId;

View File

@@ -1,7 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -5,7 +5,6 @@
*/ */
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) $isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -1,8 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php'; require_once __DIR__ . '/../../../src/Database.php';

View File

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

View File

@@ -1,7 +1,6 @@
<?php <?php
require_once __DIR__ . "/../../../bootstrap.php"; require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Database.php';

View File

@@ -1,7 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php <?php
require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.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(); AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ $importResults = [];
$importDone = false; $importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { 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'])) { if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide."; $importErrors[] = "Erreur de sécurité : token invalide.";
} else { } else {

View File

@@ -2,8 +2,6 @@
require_once __DIR__ . '/../../bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.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()) { if (!AdminAuth::hasPassword()) {
header('Location: /admin/'); header('Location: /admin/');
exit; exit;
@@ -24,8 +22,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$pageTitle = 'Connexion'; $pageTitle = 'Connexion';
$isAdmin = true; $bodyClass = 'admin-body'; $isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php'; require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php'; include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/login.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) { if ($thesisId !== false && $thesisId > 0) {
try { try {
$db = new Database(); $db = new Database();
$thesis = $db->getThesis($thesisId); // Student-mode preview: only show published theses.
$thesis = $studentMode
? $db->getThesisById($thesisId)
: $db->getThesis($thesisId);
if (!$thesis) { if (!$thesis) {
$error = "TFE non trouvé."; $error = "TFE non trouvé.";

View File

@@ -9,7 +9,13 @@
require_once __DIR__ . '/../../bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.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(); $flash = App::consumeFlash();

View File

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

View File

@@ -13,23 +13,32 @@ details > summary::-webkit-details-marker {
} }
details { 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 { summary {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
color: var(--accent-secondary); color: var(--text-primary);
transition: color 0.15s; transition: color 0.15s, background 0.15s;
padding: var(--space-s);
svg { cursor: pointer;
fill: var(--accent-secondary);
vertical-align: text-bottom;
height: 1.4em;
}
} }
summary:hover { summary:hover {
color: var(--accent-primary); color: var(--accent-primary);
background: var(--hover-bg, rgba(0, 0, 0, 0.03));
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,14 @@
* parse errors instead of silently swallowing them (unlike the * parse errors instead of silently swallowing them (unlike the
* old autosave.js .catch(() => {}) pattern). * 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 form = event.target.closest("form");
const status = form ? form.querySelector("[data-autosave-status]") : null; const status = form ? form.querySelector("[data-autosave-status]") : null;
@@ -56,11 +63,17 @@ function _handleAutosaveResponse(event) {
// Show saving indicator while request is in flight // Show saving indicator while request is in flight
document.body.addEventListener("htmx:beforeRequest", (e) => { 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; const el = e.target;
if (!el) return; const form = el?.closest?.("form");
const status = el.querySelector("[data-autosave-status]"); const status = form?.querySelector?.("[data-autosave-status]");
if (status) { if (status) {
status.textContent = "Enregistrement…"; status.textContent = "Enregistrement…";
status.className = "autosave-status autosave-status--saving"; 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) // Filter out PACS from AP programs for student forms (spec: admin-only AP)
$apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['code'] ?? '') !== 'PACS')); $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]); unset($_SESSION['form_data_share_' . $slug]);
// Determine allowed objet values for this link // Determine allowed objet values for this link
@@ -324,7 +328,8 @@ function renderShareLinkForm(string $slug, array $link): void
// ── Shared form variables ────────────────────────────────────────────── // ── Shared form variables ──────────────────────────────────────────────
$mode = 'partage'; $mode = 'partage';
$formAction = '/partage/' . urlencode($slug) . '/submit'; $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; $oldFn = $shareOldFn;
$withAutofocusFn = $shareWithAutofocusFn; $withAutofocusFn = $shareWithAutofocusFn;
@@ -399,6 +404,11 @@ function renderShareLinkForm(string $slug, array $link): void
$currentContextNote = null; $currentContextNote = null;
$currentContactVisible = null; $currentContactVisible = null;
// ── Autosave wiring ─────────────────────────────────────────────────┐
$autosaveUrl = '/partage/fragments/draft.php?slug=' . urlencode($slug);
$formExtraAttrs = '';
$showAutosaveStatus = true;
include APP_ROOT . '/templates/partage/form-page.php'; include APP_ROOT . '/templates/partage/form-page.php';
?> ?>
<main id="main-content"> <main id="main-content">
@@ -537,6 +547,8 @@ function handleShareLinkSubmission(string $slug): void
unset($_SESSION['share_verified_' . $slug]); unset($_SESSION['share_verified_' . $slug]);
unset($_SESSION['share_active']); unset($_SESSION['share_active']);
unset($_SESSION['share_primed_files_' . $slug]); 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 // Clear FilePond temp file tracking — files have been moved to permanent storage
unset($_SESSION['filepond_tmp']); unset($_SESSION['filepond_tmp']);

View File

@@ -18,7 +18,8 @@ if ($thesisId <= 0) {
} }
$db = Database::getInstance(); $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) { if (!$thesis) {
http_response_code(404); http_response_code(404);
die('TFE introuvable.'); die('TFE introuvable.');

View File

@@ -3,9 +3,7 @@
/** /**
* Minimal PHP session guard for the admin panel. * Minimal PHP session guard for the admin panel.
* *
* This is a defence-in-depth layer that sits behind nginx Basic Auth. * Password-only authentication via an HTML login form.
* It protects against proxy misconfiguration, bypass, and local-dev
* scenarios where the reverse proxy may be absent.
* *
* The admin password hash is stored in the site_settings table * The admin password hash is stored in the site_settings table
* (key = 'admin_password_hash'). * (key = 'admin_password_hash').
@@ -17,6 +15,10 @@ class AdminAuth
private const SESSION_KEY = 'admin_authenticated'; private const SESSION_KEY = 'admin_authenticated';
private const LOGIN_URL = '/admin/login.php'; 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. * Start the PHP session with hardened cookie parameters.
* Idempotent — safe to call even if session is already active. * Idempotent — safe to call even if session is already active.
@@ -61,10 +63,7 @@ class AdminAuth
* Authentication order: * Authentication order:
* 1. No password hash configured → dev mode, pass through. * 1. No password hash configured → dev mode, pass through.
* 2. Session already authenticated → pass through. * 2. Session already authenticated → pass through.
* 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] * 3. Neither → redirect to the PHP login form.
* → 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.
*/ */
public static function requireLogin(): void public static function requireLogin(): void
{ {
@@ -76,11 +75,6 @@ class AdminAuth
if (!empty($_SESSION[self::SESSION_KEY])) { if (!empty($_SESSION[self::SESSION_KEY])) {
return; // Already authenticated via session. 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); header('Location: ' . self::LOGIN_URL);
exit; exit;
} }
@@ -89,15 +83,54 @@ class AdminAuth
* Validate a plaintext password against the stored hash. * Validate a plaintext password against the stored hash.
* On success: regenerates the session ID and marks the session authenticated. * 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. * @return bool true on success, false on wrong password / no hash stored.
*/ */
public static function login(string $password): bool public static function login(string $password): bool
{ {
$storedHash = self::getStoredHash(); $storedHash = self::getStoredHash();
if ($storedHash === null || !self::verifyHash($password, $storedHash)) { if ($storedHash === null) {
return false; return false;
} }
self::startSession(); 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_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = true; $_SESSION[self::SESSION_KEY] = true;
$_SESSION['admin_login_at'] = time(); $_SESSION['admin_login_at'] = time();
@@ -145,12 +178,6 @@ class AdminAuth
if (!empty($_SESSION[self::SESSION_KEY])) { if (!empty($_SESSION[self::SESSION_KEY])) {
return true; 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; return false;
} }

View File

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

View File

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

View File

@@ -162,6 +162,8 @@ class ThesisCreateController
'exemplaire_baiu' => $data['exemplaireBaiu'], 'exemplaire_baiu' => $data['exemplaireBaiu'],
'exemplaire_erg' => $data['exemplaireErg'], 'exemplaire_erg' => $data['exemplaireErg'],
'cc2r' => $data['cc2r'], 'cc2r' => $data['cc2r'],
'duration_value' => $data['durationValue'],
'duration_unit' => $data['durationUnit'],
]); ]);
$identifier = $this->db->getThesisIdentifier($thesisId); $identifier = $this->db->getThesisIdentifier($thesisId);
@@ -548,6 +550,22 @@ class ThesisCreateController
$exemplaireErg = !empty($post['exemplaire_erg']); $exemplaireErg = !empty($post['exemplaire_erg']);
$cc2r = !empty($post['cc2r']); $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 // Annexes are optional — no validation required
$hasAnnexes = !empty($post['has_annexes']); $hasAnnexes = !empty($post['has_annexes']);
@@ -577,7 +595,9 @@ class ThesisCreateController
'juryPoints', 'juryPoints',
'exemplaireBaiu', 'exemplaireBaiu',
'exemplaireErg', 'exemplaireErg',
'cc2r' 'cc2r',
'durationValue',
'durationUnit'
); );
} }

View File

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

View File

@@ -41,18 +41,7 @@ class Database
*/ */
private function runMigrations(): void private function runMigrations(): void
{ {
// Add 'name' column to share_links if missing (new DatabaseMigrations($this->pdo))->run();
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
}
} }
/** /**
@@ -2037,7 +2026,7 @@ class Database
public function getThesisRawFields(int $thesisId): ?array public function getThesisRawFields(int $thesisId): ?array
{ {
$stmt = $this->pdo->prepare( $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]); $stmt->execute([$thesisId]);
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -2181,6 +2170,8 @@ class Database
exemplaire_baiu = ?, exemplaire_baiu = ?,
exemplaire_erg = ?, exemplaire_erg = ?,
cc2r = ?, cc2r = ?,
duration_value = ?,
duration_unit = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
"); ");
@@ -2212,6 +2203,8 @@ class Database
!empty($data['exemplaire_baiu']) ? 1 : 0, !empty($data['exemplaire_baiu']) ? 1 : 0,
!empty($data['exemplaire_erg']) ? 1 : 0, !empty($data['exemplaire_erg']) ? 1 : 0,
!empty($data['cc2r']) ? 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, $thesisId,
]); ]);
$stmt->execute($params); $stmt->execute($params);
@@ -2262,8 +2255,9 @@ class Database
remarks, jury_points, remarks, jury_points,
exemplaire_baiu, exemplaire_erg, exemplaire_baiu, exemplaire_erg,
cc2r, cc2r,
duration_value, duration_unit,
submitted_at submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
'); ');
$validObjet = ['tfe', 'thèse', 'frart']; $validObjet = ['tfe', 'thèse', 'frart'];
@@ -2296,6 +2290,8 @@ class Database
!empty($data['exemplaire_baiu']) ? 1 : 0, !empty($data['exemplaire_baiu']) ? 1 : 0,
!empty($data['exemplaire_erg']) ? 1 : 0, !empty($data['exemplaire_erg']) ? 1 : 0,
!empty($data['cc2r']) ? 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(); $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. * called from both the admin panel and the student partage form.
* *
* Auth is checked by the caller before invoking these methods: * 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 * - Partagé endpoints: session_start() + verify share_active + CSRF
* *
* All paths in this file assume the session is already started and CSRF is * 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/beforeunload-guard.js',
'/assets/js/app/pill-search.js', '/assets/js/app/pill-search.js',
'/assets/js/app/jury-autocomplete.js', '/assets/js/app/jury-autocomplete.js',
'/assets/js/app/autosave-handler.js',
], ],
]; ];
} }
@@ -117,13 +118,47 @@ class FormBootstrap
$generalitiesHtml = $helpFn('fieldset_generalites'); $generalitiesHtml = $helpFn('fieldset_generalites');
$defaultAccessTypeId = $options['defaultAccessTypeId'] ?? 2; $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([ return array_merge([
// Base // Base
'mode' => $mode, 'mode' => $mode,
'formAction' => $formAction, 'formAction' => $formAction,
'hiddenFields' => $hiddenFields, 'hiddenFields' => $hiddenFields . $extraHidden,
'errorFieldName' => $autofocusField, 'errorFieldName' => $autofocusField,
'synopsisExtra' => $options['synopsisExtra'] ?? '', 'synopsisExtra' => $options['synopsisExtra'] ?? '',
'formExtraAttrs' => $formExtraAttrs,
'showAutosaveStatus' => $showAutosaveStatus,
'autosaveUrl' => $autosaveUrl,
// Helpers // Helpers
'helpFn' => $helpFn, 'helpFn' => $helpFn,
@@ -174,6 +209,8 @@ class FormBootstrap
'contactPublic' => false, 'contactPublic' => false,
'currentContextNote' => null, 'currentContextNote' => null,
'currentContactVisible' => null, 'currentContactVisible' => null,
'currentDurationValue' => null,
'currentDurationUnit' => 'pages',
// Files (edit mode) // Files (edit mode)
'currentCover' => null, '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]); )->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. * Increment the usage count for a share link.
*/ */

View File

@@ -89,6 +89,8 @@ CREATE TABLE IF NOT EXISTS theses (
context_note TEXT, context_note TEXT,
contact_visible TEXT DEFAULT NULL, contact_visible TEXT DEFAULT NULL,
remarks TEXT, remarks TEXT,
duration_value REAL,
duration_unit TEXT DEFAULT 'pages',
access_type_id INTEGER, access_type_id INTEGER,
license_id INTEGER, license_id INTEGER,
jury_points DECIMAL(4,2), jury_points DECIMAL(4,2),
@@ -410,6 +412,8 @@ SELECT
t.synopsis, t.synopsis,
t.context_note, t.context_note,
t.contact_visible, t.contact_visible,
t.duration_value,
t.duration_unit,
at.name as access_type, at.name as access_type,
lt.name as license_type, lt.name as license_type,
t.license_id, 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 ('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 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 ('Approfondie');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement'); INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisé'); 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 ('français');
INSERT OR IGNORE INTO languages (name) VALUES ('anglais'); 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 ('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 ('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 ('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 ('about', 'À propos', 'Contenu à venir', 1);
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('charte', 'Charte', '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 remarks TEXT, -- Internal remarks
-- Duration/size -- Duration/size
duration_value REAL,
duration_unit TEXT DEFAULT 'pages',
access_type_id INTEGER, access_type_id INTEGER,
license_id INTEGER, license_id INTEGER,
@@ -4347,6 +4349,8 @@ SELECT
ft.name as finality_type, ft.name as finality_type,
t.synopsis, t.synopsis,
t.context_note, t.context_note,
t.duration_value,
t.duration_unit,
at.name as access_type, at.name as access_type,
lt.name as license_type, lt.name as license_type,
t.license_id, t.license_id,

View File

@@ -152,6 +152,7 @@
<th scope="col">Utilisations</th> <th scope="col">Utilisations</th>
<th scope="col">Expiration</th> <th scope="col">Expiration</th>
<th scope="col">Créé le</th> <th scope="col">Créé le</th>
<th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -175,6 +176,12 @@
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td> <td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
<td><?= $expires ?></td> <td><?= $expires ?></td>
<td><?= $created ?></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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -641,6 +648,10 @@ function _executeArchiveLink() {
const form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId); const form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId);
if (form) form.submit(); if (form) form.submit();
} }
function openDeleteArchivedLinkDialog(id) {
document.getElementById('delete-archived-link-id').value = id;
document.getElementById('delete-archived-link-dialog').showModal();
}
</script> </script>
<!-- Archive link confirm --> <!-- Archive link confirm -->
@@ -658,3 +669,24 @@ function _executeArchiveLink() {
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button> <button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div> </div>
</dialog> </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> <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'): ?> <?php if ($editType === 'about_page'): ?>
<!-- ── Markdown content ──────────────────────────────────────────────── --> <!-- ── Markdown content ──────────────────────────────────────────────── -->
<h2>Contenu de la page</h2> <h2>Contenu de la page</h2>
<form action="/admin/actions/page.php" method="post" class="admin-form" <form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
hx-post="/admin/actions/page.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="about"> <input type="hidden" name="slug" value="about">
<label for="editor">Contenu (Markdown) :</label> <div class="full-editor-toolbar">
<button type="button" class="btn btn--sm" <span class="full-editor-label">Contenu (Markdown) :</span>
hx-get="/admin/markdown-cheatsheet-fragment.php" <button type="button" class="btn btn--sm"
hx-target="#md-cheatsheet-container" hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-swap="innerHTML" hx-target="#md-cheatsheet-container"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()"> hx-swap="innerHTML"
Aide Markdown hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
</button> Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content" <input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>"> value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div> <div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form> </form>
<!-- ── Contacts ──────────────────────────────────────────────────────── --> <!-- ── Contacts ──────────────────────────────────────────────────────── -->
@@ -137,50 +135,46 @@
</script> </script>
<?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?> <?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?>
<form action="/admin/actions/page.php" method="post" class="admin-form" <form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
hx-post="/admin/actions/page.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>"> <input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
<label for="editor">Contenu (Markdown) :</label> <div class="full-editor-toolbar">
<button type="button" class="btn btn--sm" <span class="full-editor-label">Contenu (Markdown) :</span>
hx-get="/admin/markdown-cheatsheet-fragment.php" <button type="button" class="btn btn--sm"
hx-target="#md-cheatsheet-container" hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-swap="innerHTML" hx-target="#md-cheatsheet-container"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()"> hx-swap="innerHTML"
Aide Markdown hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
</button> Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content" <input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>"> value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div> <div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form> </form>
<?php elseif ($editType === 'form_help'): ?> <?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> <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" <form action="/admin/actions/form-help.php" method="post" class="admin-form admin-form--full-editor">
hx-post="/admin/actions/form-help.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>"> <input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>">
<label for="editor">Contenu (Markdown) :</label> <div class="full-editor-toolbar">
<button type="button" class="btn btn--sm" <span class="full-editor-label">Contenu (Markdown) :</span>
hx-get="/admin/markdown-cheatsheet-fragment.php" <button type="button" class="btn btn--sm"
hx-target="#md-cheatsheet-container" hx-get="/admin/markdown-cheatsheet-fragment.php"
hx-swap="innerHTML" hx-target="#md-cheatsheet-container"
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()"> hx-swap="innerHTML"
Aide Markdown hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
</button> Aide Markdown
</button>
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
</div>
<input type="hidden" id="content" name="content" <input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>"> value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div> <div id="editor"></div>
<div class="autosave-status" data-autosave-status></div>
</form> </form>
<?php else: ?> <?php else: ?>

View File

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

View File

@@ -360,14 +360,14 @@
<!-- Danger zone: remove credentials --> <!-- Danger zone: remove credentials -->
<?php if ($hasPassword): ?> <?php if ($hasPassword): ?>
<fieldset class="param-danger-zone"> <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> <p>
Supprime le hash de la base de données. L'accès admin 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> </p>
<?php /* TODO: replace this browser confirm() with a proper <dialog> modal like the other confirmations */ ?> <?php /* TODO: replace this browser confirm() with a proper <dialog> modal like the other confirmations */ ?>
<form method="post" action="/admin/actions/account.php" <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="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials"> <input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php"> <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. // header.php — unified site header for public and admin sections.
// Reads: $isAdmin (bool), $currentNav (string, public only) // Reads: $isAdmin (bool), $currentNav (string, public only)
$_isAdmin = !empty($isAdmin); $_isAdmin = !empty($isAdmin);
$_isLogin = !empty($isLogin);
$_navCurrent = $currentNav ?? ''; $_navCurrent = $currentNav ?? '';
$_currentPage = basename($_SERVER['PHP_SELF']); $_currentPage = basename($_SERVER['PHP_SELF']);
$_thesisId = $_GET['id'] ?? null; $_thesisId = $_GET['id'] ?? null;
@@ -9,7 +10,7 @@ $_thesisId = $_GET['id'] ?? null;
<header> <header>
<a href="#main-content" class="skip-link">Aller au contenu principal</a> <a href="#main-content" class="skip-link">Aller au contenu principal</a>
<?php if ($_isAdmin): ?> <?php if ($_isAdmin && !$_isLogin): ?>
<nav aria-label="Navigation admin"> <nav aria-label="Navigation admin">
<ul class="nav-left-links"> <ul class="nav-left-links">
@@ -87,7 +88,7 @@ $_thesisId = $_GET['id'] ?? null;
</header> </header>
<?php if ($_isAdmin): ?> <?php if ($_isAdmin && !$_isLogin): ?>
<div class="admin-mobile-block"> <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> <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> <h2>Section administrateur</h2>
@@ -95,7 +96,7 @@ $_thesisId = $_GET['id'] ?? null;
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!$_isAdmin): ?> <?php if (!$_isAdmin && !$_isLogin): ?>
<?php <?php
// Search bar — public section only (rendered below header for equal height) // Search bar — public section only (rendered below header for equal height)
$searchBarValue = $searchBarValue ?? $_GET['query'] ?? ''; $searchBarValue = $searchBarValue ?? $_GET['query'] ?? '';

View File

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

View File

@@ -64,4 +64,6 @@ $ariaDescribedBy = ($errorFieldName === $name) ? ' aria-describedby="flash-error
</fieldset> </fieldset>
</div> </div>
<?php <?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-queue-type="cover"
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>' data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>'
aria-describedby="couverture-hint"> 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> </div>
<?php if ($editMode): ?> <?php if ($editMode): ?>
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger" <button type="button" class="btn btn--sm btn--ghost file-browser-trigger"

View File

@@ -20,7 +20,7 @@ $adminMode = $adminMode ?? false;
$name = 'couverture'; $name = 'couverture';
$label = 'Image de couverture (optionnel) :'; $label = 'Image de couverture (optionnel) :';
$accept = 'image/jpeg,image/png,image/webp'; $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'; include APP_ROOT . '/templates/partials/form/file-field.php';
?> ?>

View File

@@ -102,6 +102,10 @@ $existingWebsiteUrl = $existingWebsiteUrl ?? '';
$existingWebsiteLabel = $existingWebsiteLabel ?? ''; $existingWebsiteLabel = $existingWebsiteLabel ?? '';
$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; $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()) // WCAG 3.3.1: which field has a validation error (set by caller from App::consumeAutofocus())
$errorFieldName = $errorFieldName ?? null; $errorFieldName = $errorFieldName ?? null;
?> ?>
@@ -413,8 +417,38 @@ if ($filesMode === 'add'): ?>
<?php endif; ?> <?php endif; ?>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ <!-- ═══════════════════ Durée ═══════════════════ -->
(Durée/Nombre de pages supprimés — redondants avec les fichiers attachés) --> <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 ═══════════════════ --> <!-- ═══════════════════ Degrés d'ouverture et licences ═══════════════════ -->
<?php <?php
@@ -552,6 +586,16 @@ if ($filesMode === 'add'): ?>
<?php endif; ?> <?php endif; ?>
<?php if ($showAutosaveStatus): ?> <?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> <div class="autosave-status" data-autosave-status></div>
<?php endif; ?> <?php endif; ?>

View File

@@ -73,4 +73,6 @@ foreach ($attrs as $k => $v) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php <?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> </div>
<?php <?php
// Reset consumed variables so includes in a loop don't bleed state. // 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' => 'years', 'dim' => 'years', 'heading' => 'Années'],
['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'], ['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'],
['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'], ['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'], ['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'],
]; ];
@@ -118,7 +118,7 @@ foreach ($renderOrder as $colKey):
if ($colKey === 'students'): ?> if ($colKey === 'students'): ?>
<!-- ÉTUDIANTES --> <!-- ÉTUDIANTES -->
<section class="repertoire-col" data-col="students"> <section class="repertoire-col" data-col="students">
<h2>Étudiantes</h2> <h2>Étudiant·es</h2>
<ul> <ul>
<?php if (empty($studentWorks)): ?> <?php if (empty($studentWorks)): ?>
<li class="rep-empty">—</li> <li class="rep-empty">—</li>
@@ -147,7 +147,7 @@ foreach ($renderOrder as $colKey):
<?php else: <?php else:
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; ?> $col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; ?>
<section class="repertoire-col" data-col="<?= $col['dim'] ?>"> <section class="repertoire-col" data-col="<?= $col['dim'] ?>">
<h2><?= htmlspecialchars($col['heading']) ?></h2> <h2><?= $col['heading'] ?></h2>
<ul> <ul>
<?php foreach ($repData[$col['dataKey']] as $item): <?php foreach ($repData[$col['dataKey']] as $item):
repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $colHasMatches[$col['dim']], $hx); repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $colHasMatches[$col['dim']], $hx);

View File

@@ -1,9 +1,30 @@
<main class="apropos-main" id="main-content"> <main class="apropos-main" id="main-content">
<div class="prose apropos-single"> <div class="apropos-layout">
<?php if (!empty(trim($content))): ?>
<?= $html ?> <!-- LEFT: sticky table of contents -->
<?php else: ?> <?php if (!empty($tocItems)): ?>
<p>Contenu à venir.</p> <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; ?> <?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> </div>
</main> </main>

View File

@@ -1,9 +1,30 @@
<main class="apropos-main" id="main-content"> <main class="apropos-main" id="main-content">
<div class="prose apropos-single"> <div class="apropos-layout">
<?php if (!empty(trim($content))): ?>
<?= $html ?> <!-- LEFT: sticky table of contents -->
<?php else: ?> <?php if (!empty($tocItems)): ?>
<p>Contenu à venir.</p> <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; ?> <?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> </div>
</main> </main>

View File

@@ -17,18 +17,6 @@
</select> </select>
</label> </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 <label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap"> <select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option> <option value="">Tous</option>
@@ -41,6 +29,18 @@
</select> </select>
</label> </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é <label class="search-filter-label" for="filter-finality">Finalité
<select class="search-filter-select" name="finality" id="filter-finality"> <select class="search-filter-select" name="finality" id="filter-finality">
<option value="">Toutes</option> <option value="">Toutes</option>

View File

@@ -46,6 +46,27 @@
</p> </p>
<?php endif; ?> <?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"])): ?> <?php if (!empty($data["languages"])): ?>
<p class="tfe-meta-item"> <p class="tfe-meta-item">
<span class="tfe-meta-label">Langue :</span> <span class="tfe-meta-label">Langue :</span>

View File

@@ -97,7 +97,8 @@ deploy-deps:
composer install --no-dev --no-interaction --optimize-autoloader && \ composer install --no-dev --no-interaction --optimize-autoloader && \
sha256sum composer.lock | cut -d" " -f1 > vendor/.composer-lock-checksum; \ sha256sum composer.lock | cut -d" " -f1 > vendor/.composer-lock-checksum; \
else \ else \
echo "→ composer.lock unchanged, skipping composer install"; \ echo "→ composer.lock unchanged, dumping autoloader (new classes may exist)…"; \
composer dump-autoload --optimize --no-interaction; \
fi' fi'
[group('deploy')] [group('deploy')]

View File

@@ -8,7 +8,6 @@ This directory contains nginx configuration and documentation for the Post-ERG t
- **`docs/`** - Documentation - **`docs/`** - Documentation
- `PRODUCTION_DEPLOYMENT.md` - Deployment guide - `PRODUCTION_DEPLOYMENT.md` - Deployment guide
- `QUICK_REFERENCE.md` - Command reference - `QUICK_REFERENCE.md` - Command reference
- `ADMIN_USERS.md` - User management
- `SECURITY_HEADERS.md` - Security headers reference - `SECURITY_HEADERS.md` - Security headers reference
- `PHP_AUTH_LAYER.md` - Authentication layer documentation - `PHP_AUTH_LAYER.md` - Authentication layer documentation
- `HTACCESS_TO_NGINX.md` - Apache to nginx migration notes - `HTACCESS_TO_NGINX.md` - Apache to nginx migration notes
@@ -33,19 +32,16 @@ The deployment script will:
- ✅ Test and reload nginx - ✅ Test and reload nginx
- ✅ Verify PHP-FPM is running - ✅ Verify PHP-FPM is running
### Manage admin users ### Manage admin password
```bash The admin password is managed via the admin panel at `/admin/parametres` → Account tab.
just manage-admin-users
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
```
## 🔒 Security Features ## 🔒 Security Features
### Admin Panel Protection ### Admin Panel Protection
- **Password required** for `/admin/` - **Password required** for `/admin/` (password-only, no username)
- HTTP Basic Authentication - PHP session-based authentication (`AdminAuth`)
- Rate limited: 10 requests/minute - Rate limited: 300 req/min, burst=30
### File Access Protection ### File Access Protection
- Database files (`.db`) - **BLOCKED** - Database files (`.db`) - **BLOCKED**
@@ -59,7 +55,7 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
### Rate Limiting ### Rate Limiting
- General requests: 30/minute - General requests: 30/minute
- Search endpoint: 30/minute - Search endpoint: 30/minute
- Admin panel: 10/minute - Admin panel: 300 req/min (burst=30)
### Security Headers ### Security Headers
- ✅ X-Frame-Options (clickjacking protection) - ✅ X-Frame-Options (clickjacking protection)
@@ -72,7 +68,6 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
- **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Complete deployment guide - **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Complete deployment guide
- **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference and troubleshooting - **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference and troubleshooting
- **[docs/ADMIN_USERS.md](docs/ADMIN_USERS.md)** - Admin user management
- **[docs/SECURITY_HEADERS.md](docs/SECURITY_HEADERS.md)** - Security headers reference - **[docs/SECURITY_HEADERS.md](docs/SECURITY_HEADERS.md)** - Security headers reference
## 🧪 Testing ## 🧪 Testing
@@ -90,11 +85,6 @@ curl -I https://xamxam.erg.be/ | grep -E "X-|Strict-Transport"
## 🆘 Quick Help ## 🆘 Quick Help
### Admin can't log in
```bash
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin
```
### 502 Bad Gateway ### 502 Bad Gateway
```bash ```bash
sudo systemctl status php8.4-fpm sudo systemctl status php8.4-fpm

View File

@@ -26,10 +26,7 @@ sudo bash /tmp/deploy-server.sh
### 3. Set admin password (first time only) ### 3. Set admin password (first time only)
```bash Visit `/admin/parametres` → Account tab and set the admin password there.
just manage-admin-users
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
```
## Manual Setup Steps ## Manual Setup Steps
@@ -37,15 +34,16 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
```bash ```bash
sudo apt update sudo apt update
sudo apt install nginx apache2-utils php8.4-fpm sudo apt install nginx php8.4-fpm php8.4-curl php8.4-sqlite3
``` ```
### 2. Create Admin Password ### 2. Set admin password
Visit `/admin/parametres` → Account tab in the admin panel to set the password.
Or generate a hash and insert it directly:
```bash ```bash
just manage-admin-users php -r "echo password_hash('your-secret-password', PASSWORD_BCRYPT);"
# Then on the server:
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
``` ```
### 3. Copy Nginx Configuration ### 3. Copy Nginx Configuration
@@ -69,11 +67,8 @@ sudo systemctl status nginx
### Test Admin Authentication ### Test Admin Authentication
```bash ```bash
# Should return 401 # Should redirect to login page (302)
curl -I https://xamxam.erg.be/admin/ curl -I https://xamxam.erg.be/admin/
# With credentials
curl -u admin:password https://xamxam.erg.be/admin/
``` ```
### Test File Protection ### Test File Protection
@@ -92,12 +87,6 @@ curl -I https://xamxam.erg.be/ | grep -E "X-|Strict-Transport"
## Troubleshooting ## Troubleshooting
### 403 Forbidden on admin
```bash
sudo ls -l /etc/nginx/.htpasswd-xamxam
sudo chmod 644 /etc/nginx/.htpasswd-xamxam
```
### 502 Bad Gateway ### 502 Bad Gateway
```bash ```bash
sudo systemctl status php8.4-fpm sudo systemctl status php8.4-fpm
@@ -112,8 +101,10 @@ sudo nginx -t
## Maintenance ## Maintenance
### Change Admin Password ### Change Admin Password
Visit `/admin/parametres` → Account tab or generate a new hash:
```bash ```bash
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin php -r "echo password_hash('new-password', PASSWORD_BCRYPT);"
``` ```
### Reload Configuration ### Reload Configuration
@@ -125,4 +116,4 @@ sudo nginx -t && sudo systemctl reload nginx
- **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Detailed deployment - **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Detailed deployment
- **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference - **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference
- **[docs/ADMIN_USERS.md](docs/ADMIN_USERS.md)** - User management - **[docs/PHP_AUTH_LAYER.md](docs/PHP_AUTH_LAYER.md)** - Auth layer documentation

View File

@@ -1,275 +0,0 @@
# Managing Admin Users - Post-ERG
Quick guide to manage admin users for the Post-ERG admin panel.
---
## 🎯 Quick Commands
### Interactive Menu (Recommended)
```bash
# From your local machine
just manage-admin-users
# Then on the server
ssh xamxam
sudo bash /tmp/manage-admin-users.sh
```
This gives you an interactive menu to:
1. List all users
2. Add new user
3. Change user password
4. Delete user
5. Reset all (start fresh)
---
## 📝 Manual Commands
### List Current Users
```bash
ssh xamxam
sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam
```
### Change Password for Existing User
```bash
ssh xamxam
sudo htpasswd /etc/nginx/.htpasswd-xamxam username_here
```
You'll be prompted to enter the new password twice.
### Add New User
```bash
ssh xamxam
sudo htpasswd /etc/nginx/.htpasswd-xamxam new_username
```
### Delete User
```bash
ssh xamxam
sudo htpasswd -D /etc/nginx/.htpasswd-xamxam username_to_delete
```
### Reset Everything (Start Fresh)
```bash
ssh xamxam
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_username
```
⚠️ **Warning:** The `-c` flag creates a new file, deleting all existing users!
---
## 🚀 Deploy Management Script
To upload the interactive management script to the server:
```bash
# From your local machine
just manage-admin-users
# Or manually:
rsync -v scripts/manage-admin-users.sh xamxam:/tmp/manage-admin-users.sh
```
---
## 🔑 Current Setup
After deployment, your admin panel has:
- **URL:** https://xamxam.erg.be/admin/
- **Current user:** `test_posterg_22@`
- **Password:** Set during initial deployment
---
## 💡 Common Scenarios
### Scenario 1: Change Current Password
```bash
ssh xamxam
sudo htpasswd /etc/nginx/.htpasswd-xamxam test_posterg_22@
# Enter new password when prompted
```
### Scenario 2: Change Username
Since you can't rename users, you need to:
```bash
ssh xamxam
# Add new user
sudo htpasswd /etc/nginx/.htpasswd-xamxam new_username
# Delete old user
sudo htpasswd -D /etc/nginx/.htpasswd-xamxam test_posterg_22@
```
### Scenario 3: Forgot Username
```bash
ssh xamxam
sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam
```
### Scenario 4: Multiple Admins
```bash
ssh xamxam
# Add second admin
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin2
# Add third admin
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin3
```
All users can log into `/admin/` with their own credentials.
### Scenario 5: Start Over with New Username
```bash
ssh xamxam
# This will DELETE ALL existing users and create a new one
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_admin
```
---
## 🧪 Testing
After changing users/passwords:
```bash
# Test that password is required
curl -I https://xamxam.erg.be/admin/
# Should return: 401 Unauthorized
# Test with credentials
curl -u username:password https://xamxam.erg.be/admin/
# Should return: 200 OK
```
No nginx reload needed - changes take effect immediately!
---
## 📊 Password File Details
**Location:** `/etc/nginx/.htpasswd-xamxam`
**Format:** Standard Apache htpasswd format
```
username:$apr1$encrypted_password_hash
```
**Permissions:**
```bash
-rw-r--r-- root root /etc/nginx/.htpasswd-xamxam
```
---
## 🔒 Security Tips
1. **Use Strong Passwords**
```bash
# Generate a strong password
openssl rand -base64 32
```
2. **Avoid Common Usernames**
- ❌ Bad: `admin`, `administrator`, `root`
- ✅ Good: `xamxam_admin`, `erg_webmaster`
3. **Regular Password Changes**
- Change passwords every 3-6 months
- Change immediately if compromised
4. **Monitor Access**
```bash
# Check who's accessing the admin panel
ssh xamxam
sudo grep "admin" /var/log/nginx/xamxam_access.log
```
5. **Backup Password File**
```bash
ssh xamxam
sudo cp /etc/nginx/.htpasswd-xamxam /etc/nginx/.htpasswd-xamxam.backup
```
---
## 🆘 Troubleshooting
### "401 Unauthorized" even with correct password
**Check file exists:**
```bash
ssh xamxam
ls -la /etc/nginx/.htpasswd-xamxam
```
**Verify user exists:**
```bash
sudo cat /etc/nginx/.htpasswd-xamxam
```
**Check nginx config:**
```bash
sudo grep -A 5 "auth_basic" /etc/nginx/sites-available/xamxam
```
### Can't change password - "command not found"
**Install apache2-utils:**
```bash
ssh xamxam
sudo apt update
sudo apt install apache2-utils
```
### Password file got deleted
**Recreate it:**
```bash
ssh xamxam
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_admin
```
---
## 📞 Quick Reference
| Task | Command |
|------|---------|
| **Interactive menu** | `sudo bash /tmp/manage-admin-users.sh` |
| **List users** | `sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam` |
| **Change password** | `sudo htpasswd /etc/nginx/.htpasswd-xamxam username` |
| **Add user** | `sudo htpasswd /etc/nginx/.htpasswd-xamxam newuser` |
| **Delete user** | `sudo htpasswd -D /etc/nginx/.htpasswd-xamxam username` |
| **Reset all** | `sudo htpasswd -c /etc/nginx/.htpasswd-xamxam newuser` |
| **Generate password** | `openssl rand -base64 32` |
---
## ✅ After Making Changes
No action needed! Changes to the password file take effect immediately.
You can verify with:
```bash
curl -u username:password https://xamxam.erg.be/admin/
```
---
**Remember:** Store passwords securely using a password manager! 🔐

View File

@@ -6,53 +6,32 @@
## Overview ## Overview
The admin panel uses **two independent authentication layers** with a single UX prompt: The admin panel uses a single **PHP session-based authentication** layer.
Authentication is password-only (no username required).
| Layer | Mechanism | Configured by | | Layer | Mechanism | Configured by |
|-------|-----------|---------------| |-------|-----------|---------------|
| **1st** | nginx HTTP Basic Auth | `/etc/nginx/.htpasswd-xamxam` (see `ADMIN_USERS.md`) | | **PHP** | Session guard (`src/AdminAuth.php`) | `site_settings.admin_password_hash` in DB |
| **2nd** | PHP session guard (`src/AdminAuth.php`) | `config/admin_credentials.php` |
The user only sees **one prompt** (the browser Basic Auth dialog). PHP reads the The user sees an HTML login form at `/admin/login.php` that asks only for a
same password from `$_SERVER['PHP_AUTH_PW']` and validates it independently with password. On successful login, a PHP session is created and all admin pages
`password_verify`. On success it creates a session so subsequent requests skip use `AdminAuth::requireLogin()` to enforce the guard.
the `password_verify` call.
---
## Why two layers?
nginx Basic Auth alone is a **single point of failure**:
- Reverse-proxy misconfiguration could expose admin routes directly.
- Local development without the proxy leaves admin unprotected.
- A misconfigured `auth_basic off` block (e.g., in a nested location) could bypass it.
The PHP session guard (`AdminAuth::requireLogin()`) is ~100 lines of PHP stdlib
(`password_verify` + `session_regenerate_id`) with negligible attack surface.
## Authentication flow ## Authentication flow
``` ```
Browser → nginx Basic Auth dialog (username + password) Browser → /admin/login.php (HTML password-only form)
nginx validates against .htpasswd ──✗──▶ 401 POST password → AdminAuth::login()
│ ✓ ├─ password_verify(password, stored_hash)
│ ├─ ✓ → create session → redirect to /admin/
PHP: AdminAuth::requireLogin() │ └─ ✗ → show error, stay on login form
├─ session already live? ──✓──▶ proceed └─
├─ $_SERVER['PHP_AUTH_PW'] set?
│ └─ password_verify(PHP_AUTH_PW, ADMIN_PASSWORD_HASH)
│ ├─ ✓ → create session → proceed (normal path)
│ └─ ✗ → redirect to login form
└─ neither → redirect to login form (proxy bypass)
``` ```
The login form (`/admin/login.php`) is a **fallback** for when the reverse proxy If no password hash is stored in the DB (dev / cli-server), `AdminAuth`
is absent. In normal production use the user never sees it. is a no-op — all admin pages are open.
---
## PHP auth setup (production) ## PHP auth setup (production)
@@ -61,19 +40,13 @@ is absent. In normal production use the user never sees it.
php -r "echo password_hash('your-secret-password', PASSWORD_DEFAULT);" php -r "echo password_hash('your-secret-password', PASSWORD_DEFAULT);"
``` ```
2. Create `config/admin_credentials.php` (outside the webroot, never committed): 2. Store it in the DB via the admin panel at `/admin/parametres` (Account tab)
```php or by inserting directly:
<?php ```sql
define('ADMIN_PASSWORD_HASH', '$2y$12$<paste-hash-here>'); INSERT INTO site_settings (key, value) VALUES ('admin_password_hash', '$2y$12$...')
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
``` ```
3. The `bootstrap.php` auto-loads this file if it exists.
If `ADMIN_PASSWORD_HASH` is not defined (development / cli-server), the PHP
auth layer is a **no-op** — nginx Basic Auth remains the sole guard.
---
## Session cookie hardening (TODO item #8) ## Session cookie hardening (TODO item #8)
`AdminAuth::startSession()` calls `session_set_cookie_params()` before `AdminAuth::startSession()` calls `session_set_cookie_params()` before
@@ -87,28 +60,15 @@ auth layer is a **no-op** — nginx Basic Auth remains the sole guard.
| `Path` | `/admin` | | `Path` | `/admin` |
| `Lifetime` | `0` (session cookie, expires on browser close) | | `Lifetime` | `0` (session cookie, expires on browser close) |
This replaces all direct `session_start()` calls in admin PHP files.
---
## Logout ## Logout
A **Déconnexion** button is shown in the admin nav when `ADMIN_PASSWORD_HASH` A **Déconnexion** button is shown in the admin nav when a password hash is
is defined. It hits `/admin/logout.php` which destroys the PHP session. configured. It hits `/admin/logout.php` which destroys the PHP session.
nginx Basic Auth invalidation requires closing the browser tab / window.
--- ## Files
## Files changed | File | Purpose |
|------|---------|
| File | Change | | `src/AdminAuth.php` | Auth guard class |
|------|--------| | `public/admin/login.php` | Login form (password-only) |
| `src/AdminAuth.php` | New — auth guard class | | `public/admin/logout.php` | Logout handler |
| `config/admin_credentials.php` | New — credential store (gitignored) |
| `config/admin_credentials.example.php` | New — example / template |
| `config/bootstrap.php` | Load credentials on startup |
| `public/admin/*.php` | Replace `session_start()` with `AdminAuth::requireLogin()` |
| `public/admin/actions/*.php` | Same |
| `public/admin/login.php` | New — login form |
| `public/admin/logout.php` | New — logout handler |
| `public/admin/inc/head.php` | Logout button in nav |

View File

@@ -37,8 +37,8 @@ server {
# Security headers # Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload;" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
@@ -115,12 +115,8 @@ server {
deny all; deny all;
} }
# Admin panel - password protected # Admin panel - password protected at the PHP layer (AdminAuth)
location ^~ /admin/ { location ^~ /admin/ {
# HTTP Basic Authentication (first layer)
auth_basic "Admin Access - XAMXAM";
auth_basic_user_file /etc/nginx/.htpasswd-xamxam;
# Rate limiting for admin # Rate limiting for admin
# 300r/m rate + burst=30 allows all concurrent HTMX fragments (up to ~12 # 300r/m rate + burst=30 allows all concurrent HTMX fragments (up to ~12
# on contenus.php) while still capping brute-force at 5 req/s sustained. # on contenus.php) while still capping brute-force at 5 req/s sustained.
@@ -129,12 +125,13 @@ server {
# Content-Security-Policy - Admin policy # Content-Security-Policy - Admin policy
# script-src needs 'unsafe-inline' for the OverType editor init block # script-src needs 'unsafe-inline' for the OverType editor init block
# and the live-reload poller (dev only). Admin is already auth-gated. # and the live-reload poller (dev only). Admin is already auth-gated.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always; # 'unsafe-eval' is required by htmx (uses Function() internally).
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
# Disable directory listing # Disable directory listing
autoindex off; autoindex off;
# PHP handling for admin (AdminAuth provides second layer) # PHP handling for admin (AdminAuth provides auth layer)
location ~ \.php$ { location ~ \.php$ {
include snippets/fastcgi-php.conf; include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock; fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
@@ -168,8 +165,24 @@ server {
} }
# /media — served by front controller (MediaController validates + streams) # /media — served by front controller (MediaController validates + streams)
# Override frame-ancestors to 'self' so Firefox's built-in PDF viewer
# can display PDFs inline (Firefox uses an internal iframe for PDF.js).
# Direct fastcgi_pass to /index.php (no try_files) so add_header survives —
# try_files triggers an internal redirect to location = /index.php, which
# loses this CSP override and inherits the server-block frame-ancestors 'none'.
location = /media { location = /media {
try_files $uri /index.php$is_args$args; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'self';" always;
# Direct fastcgi_pass to /index.php (no try_files) so add_header survives.
# We can't use snippets/fastcgi-php.conf because its try_files
# $fastcgi_script_name =404 would fail — $fastcgi_script_name is /media
# (no .php extension). Include fastcgi.conf directly instead.
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param PHP_VALUE "upload_max_filesize=8192M \n post_max_size=8192M";
fastcgi_read_timeout 600;
fastcgi_send_timeout 600;
} }
# /live-reload — served by front controller # /live-reload — served by front controller

View File

@@ -113,12 +113,8 @@ server {
deny all; deny all;
} }
# Admin panel - password protected # Admin panel - password protected at the PHP layer (AdminAuth)
location ^~ /admin/ { location ^~ /admin/ {
# HTTP Basic Authentication (first layer)
auth_basic "Admin Access - Post-ERG";
auth_basic_user_file /etc/nginx/.htpasswd-xamxam;
# Rate limiting for admin # Rate limiting for admin
limit_req zone=admin burst=5 nodelay; limit_req zone=admin burst=5 nodelay;

View File

@@ -83,21 +83,6 @@ chown -R www-data:xamxam /var/www/xamxam/storage/cache
chmod -R 2775 /var/www/xamxam/storage/cache chmod -R 2775 /var/www/xamxam/storage/cache
ok "Cache dirs: created and owned by www-data:xamxam" ok "Cache dirs: created and owned by www-data:xamxam"
# ── Step 1b: htpasswd file ──────────────────────────────────────────────────────
printf "\n📋 Step 1b: Checking htpasswd file...\n"
echo "--------------------------------------"
if [ -f "/etc/nginx/.htpasswd-xamxam" ]; then
ok "htpasswd file exists: /etc/nginx/.htpasswd-xamxam"
elif [ -f "/etc/nginx/.htpasswd-posterg" ]; then
cp /etc/nginx/.htpasswd-posterg /etc/nginx/.htpasswd-xamxam
chmod 644 /etc/nginx/.htpasswd-xamxam
ok "Migrated .htpasswd-posterg → .htpasswd-xamxam"
else
warn "No htpasswd file found — admin panel will return 403 until one is created"
warn "Run: sudo htpasswd -c /etc/nginx/.htpasswd-xamxam <username>"
fi
# ── Step 2: Nginx config ────────────────────────────────────────────────────── # ── Step 2: Nginx config ──────────────────────────────────────────────────────
printf "\n📋 Step 2: Deploying nginx configuration...\n" printf "\n📋 Step 2: Deploying nginx configuration...\n"
echo "--------------------------------------------" echo "--------------------------------------------"

View File

@@ -0,0 +1,95 @@
<?php
/**
* One-shot repair: fix v_theses_full / v_theses_public after migration 040
* accidentally included the already-dropped banner_path column.
*
* Run from project root: php scripts/fix-banner-path-view.php
*
* This re-executes the view portion of migration 040 (with the fix applied)
* so the views no longer reference t.banner_path.
*/
require_once __DIR__ . '/../bootstrap.php';
$db = \App\Database::getInstance();
$pdo = $db->getPdo();
echo "Dropping broken views...\n";
$pdo->exec('DROP VIEW IF EXISTS v_theses_public');
$pdo->exec('DROP VIEW IF EXISTS v_theses_full');
echo "Recreating v_theses_full (without banner_path)...\n";
$pdo->exec("
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;
");
echo "Recreating v_theses_public...\n";
$pdo->exec("
CREATE VIEW IF NOT EXISTS v_theses_public AS
SELECT * FROM v_theses_full
WHERE is_published = 1
");
echo "Done. Views recreated without banner_path.\n";

View File

@@ -1,199 +0,0 @@
#!/bin/bash
# Manage admin users for XAMXAM nginx basic authentication
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
PASSWORD_FILE="/etc/nginx/.htpasswd-xamxam"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
exit 1
fi
# Check if htpasswd is available
if ! command -v htpasswd &> /dev/null; then
echo -e "${YELLOW}Installing apache2-utils...${NC}"
apt-get update -qq
apt-get install -y apache2-utils
fi
show_menu() {
echo ""
echo -e "${BLUE}════════════════════════════════════════${NC}"
echo -e "${BLUE} XAMXAM Admin User Management${NC}"
echo -e "${BLUE}════════════════════════════════════════${NC}"
echo ""
echo "1. List all users"
echo "2. Add new user"
echo "3. Change user password"
echo "4. Delete user"
echo "5. Reset all (create new password file)"
echo "6. Exit"
echo ""
echo -n "Choose an option [1-6]: "
}
list_users() {
echo ""
if [ ! -f "$PASSWORD_FILE" ]; then
echo -e "${YELLOW}No password file found.${NC}"
return
fi
echo -e "${GREEN}Current admin users:${NC}"
echo "────────────────────────"
cut -d: -f1 "$PASSWORD_FILE" | nl
echo ""
}
add_user() {
echo ""
echo -n "Enter new username: "
read -r USERNAME
if [ -z "$USERNAME" ]; then
echo -e "${RED}Username cannot be empty${NC}"
return
fi
# Check if user already exists
if [ -f "$PASSWORD_FILE" ] && grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
echo -e "${YELLOW}User '$USERNAME' already exists. Use option 3 to change password.${NC}"
return
fi
# Add user (use -c only if file doesn't exist)
if [ ! -f "$PASSWORD_FILE" ]; then
htpasswd -c "$PASSWORD_FILE" "$USERNAME"
else
htpasswd "$PASSWORD_FILE" "$USERNAME"
fi
echo -e "${GREEN}✓ User '$USERNAME' added successfully${NC}"
}
change_password() {
list_users
echo -n "Enter username to change password: "
read -r USERNAME
if [ -z "$USERNAME" ]; then
echo -e "${RED}Username cannot be empty${NC}"
return
fi
if [ ! -f "$PASSWORD_FILE" ]; then
echo -e "${RED}Password file not found${NC}"
return
fi
if ! grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
echo -e "${RED}User '$USERNAME' not found${NC}"
return
fi
htpasswd "$PASSWORD_FILE" "$USERNAME"
echo -e "${GREEN}✓ Password changed for user '$USERNAME'${NC}"
}
delete_user() {
list_users
echo -n "Enter username to delete: "
read -r USERNAME
if [ -z "$USERNAME" ]; then
echo -e "${RED}Username cannot be empty${NC}"
return
fi
if [ ! -f "$PASSWORD_FILE" ]; then
echo -e "${RED}Password file not found${NC}"
return
fi
if ! grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
echo -e "${RED}User '$USERNAME' not found${NC}"
return
fi
echo -n "Are you sure you want to delete user '$USERNAME'? [y/N] "
read -r CONFIRM
if [ "$CONFIRM" = "y" ] || [ "$CONFIRM" = "Y" ]; then
htpasswd -D "$PASSWORD_FILE" "$USERNAME"
echo -e "${GREEN}✓ User '$USERNAME' deleted${NC}"
else
echo "Cancelled"
fi
}
reset_all() {
echo ""
echo -e "${YELLOW}WARNING: This will delete ALL existing users!${NC}"
echo -n "Are you sure? [y/N] "
read -r CONFIRM
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
echo "Cancelled"
return
fi
# Backup existing file
if [ -f "$PASSWORD_FILE" ]; then
BACKUP="${PASSWORD_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
cp "$PASSWORD_FILE" "$BACKUP"
echo -e "${GREEN}✓ Backed up to: $BACKUP${NC}"
fi
echo ""
echo -n "Enter new username: "
read -r USERNAME
if [ -z "$USERNAME" ]; then
echo -e "${RED}Username cannot be empty${NC}"
return
fi
htpasswd -c "$PASSWORD_FILE" "$USERNAME"
echo -e "${GREEN}✓ Password file reset with user '$USERNAME'${NC}"
}
# Main loop
while true; do
show_menu
read -r CHOICE
case $CHOICE in
1)
list_users
;;
2)
add_user
;;
3)
change_password
;;
4)
delete_user
;;
5)
reset_all
;;
6)
echo ""
echo "Goodbye!"
exit 0
;;
*)
echo -e "${RED}Invalid option${NC}"
;;
esac
done