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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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